template = $template; } /** * @return string */ protected function getTemplate(){ return $this->template; } /** * get $f3 base object * @return \Base */ protected function getF3() : \Base { return \Base::instance(); } /** * get DB connection * @param string $alias * @return SQL|null */ protected function getDB(string $alias = 'PF') : ?SQL { return $this->getF3()->DB->getDB($alias); } /** * event handler for all "views" * some global template variables are set in here * @param \Base $f3 * @param $params * @return bool */ function beforeroute(\Base $f3, $params) : bool { // init user session $this->initSession($f3); if($f3->get('AJAX')){ header('Content-Type: application/json'); // send "maintenance" Header -> e.g. before server update if($modeMaintenance = (int)Config::getPathfinderData('login.mode_maintenance')){ header('Pf-Maintenance: ' . $modeMaintenance); } }else{ $this->initResource($f3); $this->setTemplate(Config::getPathfinderData('view.index')); $f3->set('tplImage', Format\Image::instance()); } return true; } /** * event handler after routing * -> render view * @param \Base $f3 */ public function afterroute(\Base $f3){ // send preload/prefetch headers $resource = Resource::instance(); if($resource->getOption('output') === 'header'){ header($resource->buildHeader(), false); } if($file = $this->getTemplate()){ // Ajax calls don´t need a page render.. // this happens on client side echo \Template::instance()->render($file); } } /** * init new Session handler * @param \Base $f3 */ protected function initSession(\Base $f3){ $session = null; if( $f3->get('SESSION_CACHE') === 'mysql' && ($db = $f3->DB->getDB('PF')) instanceof SQL ){ if(!headers_sent() && session_status() != PHP_SESSION_ACTIVE){ /** * callback() for suspect sessions * @param \DB\SQL\Session $session * @param string $sid * @return bool */ $onSuspect = function($session, $sid){ self::getLogger('SESSION_SUSPECT')->write( sprintf( self::ERROR_SESSION_SUSPECT, $sid, $session->ip(), $session->agent() )); // .. continue with default onSuspect() handler // -> destroy session return false; }; new Mysql\Session($db, 'sessions', true, $onSuspect); } } } /** * init new Resource handler * @param \Base $f3 */ protected function initResource(\Base $f3){ $resource = Resource::instance(); $resource->setOption('filePath', [ 'style' => $f3->get('BASE') . '/public/css/' . Config::getPathfinderData('version'), 'script' => $f3->get('BASE') . '/public/js/' . Config::getPathfinderData('version'), 'font' => $f3->get('BASE') . '/public/fonts', 'document' => $f3->get('BASE') . '/public/templates', 'image' => $f3->get('BASE') . '/public/img' ], true); $resource->register('style', 'pathfinder'); $resource->register('script', 'lib/require'); $resource->register('script', 'app'); $resource->register('font', 'oxygen-regular-webfont'); $resource->register('font', 'oxygen-bold-webfont'); $resource->register('font', 'fa-regular-400'); $resource->register('font', 'fa-solid-900'); $resource->register('font', 'fa-brands-400'); $resource->register('url', self::getEnvironmentData('CCP_SSO_URL'), 'prerender'); $resource->register('url', Config::getPathfinderData('api.ccp_image_server'), 'dns-prefetch'); $resource->register('url', '//i.ytimg.com', 'dns-prefetch'); // YouTube tiny embed domain $f3->set('tplResource', $resource); } /** * get cookies "state" information * -> whether user accepts cookies * @return bool */ protected function getCookieState() : bool { return (bool)count( $this->getCookieByName(self::COOKIE_NAME_STATE) ); } /** * search for existing cookies * -> either a specific cookie by its name * -> or get multiple cookies by their name (search by prefix) * @param $cookieName * @param bool $prefix * @return array */ protected function getCookieByName($cookieName, $prefix = false) : array { $data = []; if(!empty($cookieName)){ $cookieData = (array)$this->getF3()->get('COOKIE'); if($prefix === true){ // look for multiple cookies with same prefix foreach($cookieData as $name => $value){ if(strpos($name, $cookieName) === 0){ $data[$name] = $value; } } }elseif(isset($cookieData[$cookieName])){ // look for a single cookie $data[$cookieName] = $cookieData[$cookieName]; } } return $data; } /** * set/update logged in cookie by character model * -> store validation data in DB * @param Pathfinder\CharacterModel $character * @throws \Exception */ protected function setLoginCookie(Pathfinder\CharacterModel $character){ if( $this->getCookieState() ){ $expireSeconds = (int)Config::getPathfinderData('login.cookie_expire'); $expireSeconds *= 24 * 60 * 60; $timezone = $this->getF3()->get('getTimeZone')(); $expireTime = new \DateTime('now', $timezone); // add cookie expire time $expireTime->add(new \DateInterval('PT' . $expireSeconds . 'S')); // unique "selector" -> to facilitate database look-ups (small size) // -> This is preferable to simply using the database id field, // which leaks the number of active users on the application $selector = bin2hex( openssl_random_pseudo_bytes(12) ); // generate unique "validator" (strong encryption) // -> plaintext set to user (cookie), hashed version of this in DB $size = openssl_cipher_iv_length('aes-256-cbc'); $validator = bin2hex(openssl_random_pseudo_bytes($size) ); // generate unique cookie token $token = hash('sha256', $validator); // get unique cookie name for this character $name = $character->getCookieName(); $authData = [ 'characterId' => $character, 'selector' => $selector, 'token' => $token, 'expires' => $expireTime->format('Y-m-d H:i:s') ]; $authenticationModel = $character->rel('characterAuthentications'); $authenticationModel->copyfrom($authData); $authenticationModel->save(); $cookieValue = implode(':', [$selector, $validator]); // get cookie name -> save new one OR update existing cookie $cookieName = 'COOKIE.' . self::COOKIE_PREFIX_CHARACTER . '_' . $name; $this->getF3()->set($cookieName, $cookieValue, $expireSeconds); } } /** * get characters from given cookie data * -> validate cookie data * -> validate characters * -> cf. Sso->requestAuthorization() ( equivalent DB based login) * * @param array $cookieData * @param bool $checkAuthorization * @return Pathfinder\CharacterModel[] * @throws \Exception */ protected function getCookieCharacters($cookieData = [], $checkAuthorization = true) : array { $characters = []; if( $this->getCookieState() && !empty($cookieData) ){ /** * @var $characterAuth Pathfinder\CharacterAuthenticationModel */ $characterAuth = Pathfinder\AbstractPathfinderModel::getNew('CharacterAuthenticationModel'); $timezone = $this->getF3()->get('getTimeZone')(); $currentTime = new \DateTime('now', $timezone); foreach($cookieData as $name => $value){ // remove invalid cookies $invalidCookie = false; $data = explode(':', $value); if(count($data) === 2){ // cookie data is well formatted $characterAuth->getByForeignKey('selector', $data[0], ['limit' => 1]); // validate "scope hash" // -> either "normal" scopes OR "admin" scopes // "expire data" and "validate token" if( !$characterAuth->dry() ){ if( strtotime($characterAuth->expires) >= $currentTime->getTimestamp() && hash_equals($characterAuth->token, hash('sha256', $data[1])) ){ // cookie information is valid // -> try to update character information from ESI // e.g. Corp has changed, this also ensures valid "access_token" /** * @var $character Pathfinder\CharacterModel */ $updateStatus = $characterAuth->characterId->updateFromESI(); if( empty($updateStatus) ){ // make sure character data is up2date! // -> this is not the case if e.g. userCharacters was removed "ownerHash" changed... $character = $characterAuth->rel('characterId'); $character->getById( $characterAuth->get('characterId', true) ); // check ESI scopes $scopeHash = Util::getHashFromScopes($character->esiScopes); if( $scopeHash === Util::getHashFromScopes(self::getScopesByAuthType()) || $scopeHash === Util::getHashFromScopes(self::getScopesByAuthType('admin')) ){ // check if character still has user (is not the case of "ownerHash" changed // check if character is still authorized to log in (e.g. corp/ally or config has changed // -> do NOT remove cookie on failure. This can be a temporary problem (e.g. ESI is down,..) if( $character->hasUserCharacter() ){ $authStatus = $character->isAuthorized(); if( $authStatus == 'OK' || !$checkAuthorization ){ $character->virtual( 'authStatus', $authStatus); } $characters[$name] = $character; } }else{ // outdated/invalid ESI scopes $characterAuth->erase(); $invalidCookie = true; } }else{ $invalidCookie = true; } }else{ // clear existing authentication data from DB $characterAuth->erase(); $invalidCookie = true; } }else{ $invalidCookie = true; } $characterAuth->reset(); }else{ $invalidCookie = true; } // remove invalid cookie if($invalidCookie){ $this->getF3()->clear('COOKIE.' . $name); } } } return $characters; } /** * get current character from session data * @param int $ttl * @return Pathfinder\CharacterModel|null * @throws \Exception */ protected function getSessionCharacter(int $ttl = AbstractModel::DEFAULT_SQL_TTL) : ?Pathfinder\CharacterModel { $character = null; if($user = $this->getUser($ttl)){ $header = self::getRequestHeaders(); $requestedCharacterId = (int)$header['Pf-Character']; if( !$this->getF3()->get('AJAX') ){ $requestedCharacterId = (int)$_COOKIE['old_char_id']; if(!$requestedCharacterId){ $tempCharacterData = (array)$this->getF3()->get(Api\User::SESSION_KEY_TEMP_CHARACTER_DATA); if((int)$tempCharacterData['ID'] > 0){ $requestedCharacterId = (int)$tempCharacterData['ID']; } } } $character = $user->getSessionCharacter($requestedCharacterId, $ttl); } return $character; } /** * get current character * @param int $ttl * @return Pathfinder\CharacterModel|null * @throws \Exception */ public function getCharacter(int $ttl = AbstractModel::DEFAULT_SQL_TTL) : ?Pathfinder\CharacterModel { return $this->getSessionCharacter($ttl); } /** * get current user * @param int $ttl * @return Pathfinder\UserModel|null * @throws \Exception */ public function getUser($ttl = AbstractModel::DEFAULT_SQL_TTL) : ?Pathfinder\UserModel { $user = null; if($this->getF3()->exists(Api\User::SESSION_KEY_USER_ID, $userId)){ /** * @var $userModel Pathfinder\UserModel */ $userModel = Pathfinder\AbstractPathfinderModel::getNew('UserModel'); $userModel->getById($userId, $ttl); if( !$userModel->dry() && $userModel->hasUserCharacters() ){ $user = $userModel; } } return $user; } /** * set temp login character data (required during HTTP redirects on login) * @param int $characterId * @throws \Exception */ protected function setTempCharacterData(int $characterId){ if($characterId > 0){ $tempCharacterData = [ 'ID' => $characterId ]; $this->getF3()->set(Api\User::SESSION_KEY_TEMP_CHARACTER_DATA, $tempCharacterData); }else{ throw new \Exception( sprintf(self::ERROR_TEMP_CHARACTER_ID, $characterId) ); } } /** * log out current character or all active characters (multiple browser tabs) * -> send response data to client * @param \Base $f3 * @param bool $all * @param bool $deleteSession * @param bool $deleteLog * @param bool $deleteCookie * @throws \Exception */ protected function logoutCharacter(\Base $f3, bool $all = false, bool $deleteSession = true, bool $deleteLog = true, bool $deleteCookie = false){ $sessionCharacterData = (array)$f3->get(Api\User::SESSION_KEY_CHARACTERS); if($sessionCharacterData){ $activeCharacterId = ($activeCharacter = $this->getCharacter()) ? $activeCharacter->_id : 0; /** * @var $character Pathfinder\CharacterModel */ $character = Pathfinder\AbstractPathfinderModel::getNew('CharacterModel'); $characterIds = []; foreach($sessionCharacterData as $characterData){ if($characterData['ID'] === $activeCharacterId){ $characterIds[] = $activeCharacter->_id; $activeCharacter->logout($deleteSession, $deleteLog, $deleteCookie); }elseif($all){ $character->getById($characterData['ID']); $characterIds[] = $character->_id; $character->logout($deleteSession, $deleteLog, $deleteCookie); } $character->reset(); } if($characterIds){ // broadcast logout information to webSocket server $f3->webSocket()->write('characterLogout', $characterIds); } } if($f3->get('AJAX')){ $status = 403; $f3->status($status); $return = (object) []; $return->reroute = rtrim(self::getEnvironmentData('URL'), '/') . $f3->alias('login'); $return->error[] = $this->getErrorObject($status, Config::getMessageFromHTTPStatus($status)); echo json_encode($return); }else{ // redirect to landing page $f3->reroute(['login']); } } /** * get EVE server status from ESI * @param \Base $f3 * @throws \Exception */ public function getEveServerStatus(\Base $f3){ $ttl = 60; $esiStatusVersion = 'latest'; $cacheKey = 'eve_server_status'; if(!$exists = $f3->exists($cacheKey, $return)){ $return = (object) []; $return->error = []; /** * @var $client CcpClient */ if($client = $f3->ccpClient()){ $return->server = [ 'name' => strtoupper(self::getEnvironmentData('CCP_ESI_DATASOURCE')), 'status' => 'offline', 'statusColor' => 'red', ]; $return->api = [ 'name' => 'ESI API', 'status' => 'offline', 'statusColor' => 'red', 'url' => $client->getUrl(), 'timeout' => $client->getTimeout(), 'connectTimeout' => $client->getConnectTimeout(), 'readTimeout' => $client->getReadTimeout(), 'proxy' => ($proxy = $client->getProxy()) ? : 'false', 'verify' => $client->getVerify(), 'debug' => $client->getDebugRequests(), 'dataSource' => $client->getDataSource(), 'statusVersion' => $esiStatusVersion, 'routes' => [] ]; $serverStatus = $client->getServerStatus(); if( !isset($serverStatus['error']) ){ $statusData = $serverStatus['status']; // calculate time diff since last server restart $timezone = $f3->get('getTimeZone')(); $dateNow = new \DateTime('now', $timezone); $dateServerStart = new \DateTime($statusData['startTime']); $interval = $dateNow->diff($dateServerStart); $startTimestampFormat = $interval->format('%hh %im'); if($interval->days > 0){ $startTimestampFormat = $interval->days . 'd ' . $startTimestampFormat; } $statusData['name'] = $return->server['name']; $statusData['status'] = 'online'; $statusData['statusColor'] = 'green'; $statusData['startTime'] = $startTimestampFormat; $return->server = $statusData; }else{ $return->error[] = (new PathfinderException($serverStatus['error'], 500))->getError(); } $apiStatus = $client->getStatusForRoutes('latest'); if( !isset($apiStatus['error']) ){ // find top status $status = 'OK'; $color = 'green'; foreach($apiStatus['status'] as &$statusData){ if('red' == $statusData['status']){ $status = 'unstable'; $color = $statusData['status'] = 'orange'; // red is already in use for fatal API errors (e.g. no response at all, or offline) break; } if('yellow' == $statusData['status']){ $status = 'degraded'; $color = $statusData['status']; } } $return->api['status'] = $status; $return->api['statusColor'] = $color; $return->api['routes'] = $apiStatus['status']; }else{ $return->error[] = (new PathfinderException($apiStatus['error'], 500))->getError(); } if(empty($return->error)){ $f3->set($cacheKey, $return, $ttl); } } } if(empty($return->error)){ $f3->expire(Config::ttlLeft($exists, $ttl)); } echo json_encode($return); } /** * @param int $code * @param string $message * @param string $status * @param null $trace * @return \stdClass */ 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; } /** * @param string $title * @param string $message * @param string $type * @return \stdClass */ protected function getNotificationObject(string $title, $message = '', $type = 'danger') : \stdClass { $notification = (object) []; $notification->type = in_array($type, self::NOTIFICATION_TYPES) ? $type : 'danger'; $notification->title = $title; $notification->message = $message; return $notification; } /** * get a program URL by alias * -> if no $alias given -> get "default" route (index.php) * @param null $alias * @return bool|string */ protected function getRouteUrl($alias = null){ $url = false; if(!empty($alias)){ // check given alias is a valid (registered) route if(array_key_exists($alias, $this->getF3()->get('ALIASES'))){ $url = $this->getF3()->alias($alias); } }elseif($this->getF3()->get('ALIAS')){ // get current URL $url = $this->getF3()->alias( $this->getF3()->get('ALIAS') ); }else{ // get main (index.php) URL $url = $this->getF3()->alias('login'); } return $url; } /** * get a custom userAgent string for API calls * @return string */ protected function getUserAgent() : string { $userAgent = ''; $userAgent .= Config::getPathfinderData('name'); $userAgent .= ' - ' . Config::getPathfinderData('version'); $userAgent .= ' | ' . Config::getPathfinderData('contact'); $userAgent .= ' (' . $_SERVER['SERVER_NAME'] . ')'; return $userAgent; } /** * print error information in CLI mode * @param \stdClass $error */ protected function echoErrorCLI(\stdClass $error){ echo '[' . date('H:i:s') . '] ───────────────────────────' . PHP_EOL; foreach(get_object_vars($error) as $key => $value){ $row = str_pad(' ',2 ) . str_pad($key . ':',10 ); if($key == 'trace'){ $value = preg_replace("/\r\n|\r|\n/", "\n" . str_pad(' ',12 ), $value); $row .= PHP_EOL . str_pad(' ',12 ) . $value; }else{ $row .= $value; } echo $row . PHP_EOL; } } /** * onError() callback function * -> on AJAX request -> return JSON with error information * -> on HTTP request -> render error page * @param \Base $f3 * @return bool */ public function showError(\Base $f3){ if(!headers_sent()){ // collect error info ------------------------------------------------------------------------------------- $errorData = $f3->get('ERROR'); $exception = $f3->get('EXCEPTION'); if($exception instanceof PathfinderException){ // ... handle Pathfinder exceptions (e.g. validation Exceptions,..) $error = $exception->getError(); }else{ // ... handle error $f3->error() calls $error = $this->getErrorObject( $errorData['code'], $errorData['status'], $errorData['text'], $f3->get('DEBUG') >= 1 ? $errorData['trace'] : null ); } // check if error is a PDO Exception ---------------------------------------------------------------------- if(strpos(strtolower( $f3->get('ERROR.text') ), 'duplicate') !== false){ preg_match_all('/\'([^\']+)\'/', $f3->get('ERROR.text'), $matches, PREG_SET_ORDER); if(count($matches) === 2){ $error->field = $matches[1][1]; $error->message = 'Value "' . $matches[0][1] . '" already exists'; } } // set response status ------------------------------------------------------------------------------------ if(!empty($error->code)){ $f3->status($error->code); } if($f3->get('CLI')){ $this->echoErrorCLI($error); // no further processing (no HTML output) return false; }elseif($f3->get('AJAX')){ $return = (object) []; $return->error[] = $error; echo json_encode($return); }else{ // non AJAX (e.g. GET/POST) // recursively clear existing output buffers while(ob_get_level()){ ob_end_clean(); } $f3->set('tplPageTitle', 'ERROR - ' . $error->code); // set error data for template rendering $error->redirectUrl = $this->getRouteUrl(); $f3->set('errorData', $error); // 4xx/5xx error -> set error page template if( preg_match('/^4[0-9]{2}$/', $error->code) ){ $f3->set('tplPageContent', Config::getPathfinderData('STATUS.4XX') ); }elseif( preg_match('/^5[0-9]{2}$/', $error->code) ){ $f3->set('tplPageContent', Config::getPathfinderData('STATUS.5XX')); } // stop script - die(); after this fkt is done // -> unload() fkt is still called $f3->set('HALT', true); } } return true; } /** * Callback for framework "unload" * -> this function is called on each request! * -> configured in config.ini * @param \Base $f3 * @return bool */ public function unload(\Base $f3){ // store all user activities that are buffered for logging in this request // this should work even on non HTTP200 responses $this->logActivities(); return true; } /** * store activity log data to DB */ protected function logActivities(){ LogController::instance()->logActivities(); Monolog::instance()->log(); } /** * simple counter with "static" store * -> called within tpl render * @return \Closure */ protected function counter() : \Closure { $store = []; return function(string $action = 'increment', string $type = 'default', $val = 0) use (&$store){ $return = null; switch($action){ case 'increment': $store[$type]++; break; case 'add': $store[$type] += (int)$val; break; case 'get': $return = $store[$type] ? : null; break; case 'reset': unset($store[$type]); break; } return $return; }; } /** * get controller by class name * -> controller class is searched within all controller directories * @param $className * @return null|Controller * @throws \Exception */ static function getController($className){ $controller = null; // add subNamespaces for controller classes $subNamespaces = ['Api', 'Ccp']; for($i = 0; $i <= count($subNamespaces); $i++){ $path = [__NAMESPACE__]; $path[] = ( isset($subNamespaces[$i - 1]) ) ? $subNamespaces[$i - 1] : ''; $path[] = $className; $classPath = implode('\\', array_filter($path)); if(class_exists($classPath)){ $controller = new $classPath(); break; } } if( is_null($controller) ){ throw new \Exception( sprintf('Controller class "%s" not found!', $className) ); } return $controller; } /** * get scope array by a "role" * @param string $authType * @return array */ static function getScopesByAuthType(string $authType = '') : array { $scopes = array_filter((array)self::getEnvironmentData('CCP_ESI_SCOPES')); switch($authType){ case 'admin': $scopesAdmin = array_filter((array)self::getEnvironmentData('CCP_ESI_SCOPES_ADMIN')); $scopes = array_merge($scopes, $scopesAdmin); break; } sort($scopes); return $scopes; } /** * Helper function to return all headers because * getallheaders() is not available under nginx * @return array (string $key -> string $value) */ static function getRequestHeaders() : array { $headers = []; $headerPrefix = 'http_'; $prefixLength = mb_strlen($headerPrefix); $serverData = self::getServerData(); if( function_exists('apache_request_headers') && $serverData->type === 'apache' ){ // Apache WebServer $headers = apache_request_headers(); }else{ // Other WebServer, e.g. Nginx // Unfortunately this "fallback" does not work for me (Apache) // Therefore we can´t use this for all servers // https://github.com/exodus4d/pathfinder/issues/58 foreach($_SERVER as $name => $value){ $name = mb_strtolower($name); if(mb_substr($name, 0, $prefixLength) == $headerPrefix){ $headers[mb_convert_case(str_replace('_', '-', mb_substr($name, $prefixLength)), MB_CASE_TITLE)] = $value; } } } return $headers; } /** * get some server information * @param int $ttl cache time (default: 1h) * @return \stdClass */ static function getServerData($ttl = 3600){ $f3 = \Base::instance(); $cacheKey = 'PF_SERVER_INFO'; if( !$f3->exists($cacheKey) ){ $serverData = (object) []; $serverData->type = 'unknown'; $serverData->version = 'unknown'; $serverData->requiredVersion = 'unknown'; $serverData->phpInterfaceType = php_sapi_name(); if(strpos(strtolower($_SERVER['SERVER_SOFTWARE']), 'nginx' ) !== false){ // Nginx server $serverSoftwareArgs = explode('/', strtolower( $_SERVER['SERVER_SOFTWARE']) ); $serverData->type = reset($serverSoftwareArgs); $serverData->version = end($serverSoftwareArgs); $serverData->requiredVersion = $f3->get('REQUIREMENTS.SERVER.NGINX.VERSION'); }elseif(strpos(strtolower($_SERVER['SERVER_SOFTWARE']), 'apache' ) !== false){ // Apache server $serverData->type = 'apache'; $serverData->requiredVersion = $f3->get('REQUIREMENTS.SERVER.APACHE.VERSION'); // try to get the apache version... if(function_exists('apache_get_version')){ // function does not exists if PHP is running as CGI/FPM module! $matches = preg_split('/[\s,\/ ]+/', strtolower( apache_get_version() ) ); if(count($matches) > 1){ $serverData->version = $matches[1]; } } } // cache data for one day $f3->set($cacheKey, $serverData, $ttl); } return $f3->get($cacheKey); } /** * get the current registration status * 0=registration stop |1=new registration allowed * @return int */ static function getRegistrationStatus(){ return (int)Config::getPathfinderData('registration.status'); } /** * get a Logger object by Hive key * -> set in pathfinder.ini * @param string $type * @return \Log */ static function getLogger($type = 'DEBUG') : \Log { return LogController::getLogger($type); } /** * removes illegal characters from a Hive-key that are not allowed * @param $key * @return string */ static function formatHiveKey($key) : string { $illegalCharacters = ['-', ' ']; return strtolower(str_replace($illegalCharacters, '', $key)); } /** * get environment specific configuration data * @param string $key * @return string|array|null */ static function getEnvironmentData($key){ return Config::getEnvironmentData($key); } }