/** * Get the current user ID. Throws a UserNotLoggedIn exception if you aren't logged in. * * @return int * @throws UserNotLoggedIn */ public function getActiveUserId() : int { if (empty($_SESSION['userid'])) { throw new UserNotLoggedIn(\trk('errors.security.not_authenticated')); } return (int) $_SESSION['userid']; }
/** * Get the entire API for a specific version * * @param string $version * @return array * @throws InvalidConfig */ public static function getAll(string $version = self::API_VERSION) : array { switch ($version) { case '1.0.0': return ['airship_download' => '/airship_download', 'airship_version' => '/airship_version', 'fetch_keys' => '/keyggdrasil', 'version' => '/version', 'download' => '/download']; default: throw new InvalidConfig(\trk('errors.hail.invalid_api_version', $version)); } }
/** * Channel constructor. * * @param object $parent (Continuum or Keyggdrasil) * @param string $name * @param array $config * @throws \TypeError */ public function __construct($parent, string $name, array $config = []) { if ($parent instanceof Keyggdrasil || $parent instanceof Continuum) { $this->parent = $parent; } if (!\is1DArray($config['urls'])) { throw new \TypeError(\trk('errors.type.expected_1d_array')); } // The channel should be signing responses at the application layer: $this->publicKey = new SignaturePublicKey(\Sodium\hex2bin($config['publickey'])); $this->name = $name; foreach (\array_values($config['urls']) as $index => $url) { if (!\is_string($url)) { throw new \TypeError(\trk('errors.type.expected_string_array', \gettype($url), $index)); } $this->urls[] = $url; } }
/** * Store a log message -- used by Ledger * * @param string $level * @param string $message * @param string $context (JSON encoded) * @return mixed * @throws FileNotFound * @throws FileAccessDenied */ public function store(string $level, string $message, string $context) { $now = new \DateTime('now'); $filename = $now->format($this->fileFormat); \touch($this->basedir . DIRECTORY_SEPARATOR . $filename); $file = \realpath($this->basedir . DIRECTORY_SEPARATOR . $filename); if ($file === false) { throw new FileNotFound(\trk('errors.file.cannot_write_file')); } if (\strpos($file, $this->basedir) === false) { throw new FileAccessDenied(\trk('errors.file.lfi')); } if (!\file_exists($file)) { \touch($file); \chmod($file, 0770); } $time = $now->format($this->timeFormat); return \file_put_contents($file, $time . "\t" . \preg_replace('#[^a-z]*#', '', $level) . "\t" . \json_encode($message) . "\t" . $context . "\n", FILE_APPEND); }
KeyFactory::save($keys[$index], $path); break; case 'EncryptionSecretKey': $kp = KeyFactory::generateEncryptionKeyPair(); $keys[$index] = $kp->getSecretKey(); KeyFactory::save($keys[$index], $path); break; case 'SignatureSecretKey': $kp = KeyFactory::generateSignatureKeyPair(); $keys[$index] = $kp->getSecretKey(); KeyFactory::save($keys[$index], $path); break; case 'EncryptionKeyPair': $keys[$index] = KeyFactory::generateEncryptionKeyPair(); KeyFactory::save($keys[$index], $path); break; case 'SignatureKeyPair': $keys[$index] = KeyFactory::generateSignatureKeyPair(); KeyFactory::save($keys[$index], $path); break; default: throw new \Error(\trk('errors.crypto.unknown_key_type', $keyConfig['type'])); } } } // Now that we have a bunch of Keys stored in $keys, let's load them into // our singleton. $state->keyring = $keys; }; $key_management_closure(); unset($key_management_closure);
/** * Move a page to a new directory * * @param int $pageId the page we're changing * @param string $url the new URL * @param int $destinationDir the new directory * @return bool * @throws CustomPageCollisionException */ public function movePage(int $pageId, string $url = '', int $destinationDir = 0) : bool { $this->db->beginTransaction(); if ($destinationDir > 0) { $collision = $this->db->cell('SELECT COUNT(pageid) FROM airship_custom_page WHERE directory = ? AND url = ? AND pageid != ?', $destinationDir, $url, $pageId); } else { $collision = $this->db->cell('SELECT COUNT(pageid) FROM airship_custom_page WHERE directory IS NULL AND url = ? AND pageid != ?', $url, $pageId); } if ($collision > 0) { // Sorry, but no. throw new CustomPageCollisionException(\trk('errors.pages.collision')); } $this->db->update('airship_custom_page', ['url' => $url, 'directory' => $destinationDir > 0 ? $destinationDir : null], ['pageid' => $pageId]); return $this->db->commit(); }
/** * Actually serve the routes. Called by route() above. * * @param array $route * @param array $args * @return mixed * @throws FallbackLoop * @throws \Error */ protected function serve(array $route, array $args = []) { static $calledOnce = null; if (count($route) === 1) { $route[] = 'index'; } try { $class_name = Gears::getName('Landing__' . $route[0]); } catch (GearNotFound $ex) { $class_name = '\\Airship\\Cabin\\' . self::$active_cabin . '\\Landing\\' . $route[0]; } $method = $route[1]; if (!\class_exists($class_name)) { $state = State::instance(); $state->logger->error('Landing Error: Class not found when invoked from router', ['route' => ['class' => $class_name, 'method' => $method]]); $calledOnce = true; return $this->serveFallback(); } // Load our cabin-specific landing $landing = new $class_name(); if (!$landing instanceof Landing) { throw new \Error(\__("%s is not a Landing", "default", $class_name)); } // Dependency injection with a twist $landing->airshipEjectFromCockpit($this->lens, $this->databases, self::$patternPrefix); // Tighten the Bolts! \Airship\tightenBolts($landing); if (!\method_exists($landing, $method)) { if ($calledOnce) { throw new FallbackLoop(\trk('errors.router.fallback_loop')); } $calledOnce = true; return $this->serveFallback(); } return $landing->{$method}(...$args); }
/** * Update a row in a database table. * * @param string $table - table name * @param array $changes - associative array of which values should be assigned to each field * @param array $conditions - WHERE clause * @return mixed * @throws \TypeError */ public function update(string $table, array $changes, array $conditions) { if (empty($changes) || empty($conditions)) { return null; } $params = []; $queryString = "UPDATE " . $this->escapeIdentifier($table) . " SET "; // The first set (pre WHERE) $pre = []; foreach ($changes as $i => $v) { $i = $this->escapeIdentifier($i); if ($v === null) { $pre[] = " {$i} = NULL"; } elseif ($v === true) { $pre[] = " {$i} = TRUE "; } elseif ($v === false) { $pre[] = " {$i} = FALSE "; } elseif (\is_array($v)) { throw new \TypeError(\trk('errors.database.array_passed')); } else { $pre[] = " {$i} = ?"; $params[] = $v; } } $queryString .= \implode(', ', $pre); $queryString .= " WHERE "; // The last set (post WHERE) $post = []; foreach ($conditions as $i => $v) { $i = $this->escapeIdentifier($i); if ($v === null) { $post[] = " {$i} IS NULL "; } elseif ($v === true) { $post[] = " {$i} = TRUE "; } elseif ($v === false) { $post[] = " {$i} = FALSE "; } elseif (\is_array($v)) { throw new \TypeError(\trk('errors.database.array_passed')); } else { $post[] = " {$i} = ? "; $params[] = $v; } } $queryString .= \implode(' AND ', $post); return $this->safeQuery($queryString, $params); }
/** * Get the channels (cache across all instances of Installer) * * @param string $name * @return Channel * @throws NoAPIResponse */ protected function getChannel(string $name) : Channel { if (empty(self::$channels)) { $config = \Airship\loadJSON(ROOT . '/config/channels.json'); foreach ($config as $chName => $chConfig) { self::$channels[$chName] = new Channel($this, $chName, $chConfig); } } if (isset(self::$channels[$name])) { return self::$channels[$name]; } throw new NoAPIResponse(\trk('errors.hail.no_channel_configured', '')); }
/** * Get a relative BLAKE2b hash of an input. Formatted as two lookup * directories followed by a cache entry. 'hh/hh/hhhhhhhh...' * * @param string $preHash The cache identifier (will be hashed) * @param bool $asString Return a string? * @return string|array * @throws InvalidType */ public static function getRelativeHash(string $preHash, bool $asString = false) { $state = State::instance(); $cacheKey = $state->keyring['cache.hash_key']; if (!$cacheKey instanceof Key) { throw new InvalidType(\trk('errors.type.wrong_class', '\\ParagonIE\\Halite\\Key')); } // We use a keyed hash, with a distinct key per Airship deployment to // make collisions unlikely, $hash = \Sodium\crypto_generichash($preHash, $cacheKey->getRawKeyMaterial(), self::HASH_SIZE); $relHash = [\Sodium\bin2hex($hash[0]), \Sodium\bin2hex($hash[1]), \Sodium\bin2hex(Util::subString($hash, 2))]; if ($asString) { return \implode(DIRECTORY_SEPARATOR, $relHash); } return $relHash; }
$transportConfig['connection_config']['ssl'] = 'tls'; $transportConfig['port'] = !empty($state->universal['email']['smtp']['port']) ? $state->universal['email']['smtp']['port'] : 587; } $transport->setOptions(new \Zend\Mail\Transport\SmtpOptions($transportConfig)); break; case 'File': $transport = new Zend\Mail\Transport\File(); /** @noinspection PhpUnusedParameterInspection */ $transport->setOptions(new \Zend\Mail\Transport\FileOptions(['path' => !empty($state->universal['email']['file']['path']) ? $state->universal['email']['file']['path'] : ROOT . '/files/email', 'callback' => function (Zend\Mail\Transport\File $t) : string { return \implode('_', ['Message', \date('YmdHis'), \Airship\uniqueId(12) . '.txt']); }])); break; case 'Sendmail': if (!empty($state->universal['email']['sendmail']['parameters'])) { $transport = new Zend\Mail\Transport\Sendmail($state->universal['email']['sendmail']['parameters']); } else { $transport = new Zend\Mail\Transport\Sendmail(); } break; default: throw new Exception(\trk('errors.email.invalid_transport', \print_r($state->universal['email']['transport'], true))); } } $state->mailer = $transport; } // Now that our mailer is set up, let's make sure GPGMailer is too. $gpgMailer = new GPGMailer($state->mailer, ['homedir' => ROOT . '/files']); $state->gpgMailer = $gpgMailer; }; $email_closure(); unset($email_closure);
/** * Grab a blueprint * * @param string $name * @param mixed[] ...$cArgs Constructor arguments * @return Blueprint * @throws InvalidType */ protected function blueprint(string $name, ...$cArgs) : Blueprint { if (!empty($cArgs)) { $cache = Util::hash($name . ':' . \json_encode($cArgs)); } else { $cArgs = []; $cache = Util::hash($name . '[]'); } if (!isset($this->_cache['blueprints'][$cache])) { // CACHE MISS. We need to build it, then! $db = $this->airshipChooseDB(); if ($db instanceof DBInterface) { \array_unshift($cArgs, $db); } try { $class = Gears::getName('Blueprint__' . $name); } catch (GearNotFound $ex) { if ($name[0] === '\\') { // If you pass a \Absolute\Namespace, we will just use it. $class = $name; } else { // We default to \Current\Application\Namespace\Blueprint\NameHere. $x = \explode('\\', $this->getNamespace()); \array_pop($x); $class = \implode('\\', $x) . '\\Blueprint\\' . $name; } } $this->_cache['blueprints'][$cache] = new $class(...$cArgs); if (!$this->_cache['blueprints'][$cache] instanceof Blueprint) { throw new InvalidType(\trk('errors.type.wrong_class', 'Blueprint')); } \Airship\tightenBolts($this->_cache['blueprints'][$cache]); } return $this->_cache['blueprints'][$cache]; }
/** * Get the directory ID for a given path * * @param string $dir * @param int $parent * @param string $cabin * @return int * @throws CustomPageNotFoundException */ public function getDirectoryId(string $dir, int $parent = 0, string $cabin = '') : int { if (empty($cabin)) { $cabin = $this->cabin; } if (empty($parent)) { $res = $this->db->cell("SELECT\n directoryid\n FROM\n airship_custom_dir\n WHERE\n active\n AND cabin = ?\n AND url = ?\n AND parent IS NULL\n ", $cabin, $dir); } else { $res = $this->db->cell("SELECT\n directoryid\n FROM\n airship_custom_dir\n WHERE\n active\n AND cabin = ?\n AND url = ?\n AND parent = ?\n ", $cabin, $dir, $parent); } if ($res === false) { throw new CustomPageNotFoundException(\trk('errors.pages.directory_does_not_exist')); } return (int) $res; }
/** * Authenticate a user by a long-term authentication token (e.g. a cookie). * * @param string $token * @return mixed int * @throws LongTermAuthAlert */ public function loginByToken(string $token = '') : int { $table = $this->db->escapeIdentifier($this->tableConfig['table']['longterm']); $f = ['selector' => $this->db->escapeIdentifier($this->tableConfig['fields']['longterm']['selector']), 'userid' => $this->tableConfig['fields']['longterm']['userid'], 'validator' => $this->tableConfig['fields']['longterm']['validator']]; try { $decoded = Base64::decode($token); } catch (\RangeException $ex) { throw new LongTermAuthAlert(\trk('errors.security.invalid_persistent_token')); } if ($decoded === false) { throw new LongTermAuthAlert(\trk('errors.security.invalid_persistent_token')); } elseif (Binary::safeStrlen($decoded) !== self::LONG_TERM_AUTH_BYTES) { throw new LongTermAuthAlert(\trk('errors.security.invalid_persistent_token')); } \Sodium\memzero($token); $sel = Binary::safeSubstr($decoded, 0, self::SELECTOR_BYTES); $val = CryptoUtil::raw_hash(Binary::safeSubstr($decoded, self::SELECTOR_BYTES)); \Sodium\memzero($decoded); $record = $this->db->row('SELECT * FROM ' . $table . ' WHERE ' . $f['selector'] . ' = ?', Base64::encode($sel)); if (empty($record)) { \Sodium\memzero($val); throw new LongTermAuthAlert(\trk('errors.security.invalid_persistent_token')); } $stored = \Sodium\hex2bin($record[$f['validator']]); \Sodium\memzero($record[$f['validator']]); if (!\hash_equals($stored, $val)) { \Sodium\memzero($val); \Sodium\memzero($stored); throw new LongTermAuthAlert(\trk('errors.security.invalid_persistent_token')); } \Sodium\memzero($stored); \Sodium\memzero($val); $userID = (int) $record[$f['userid']]; $_SESSION['session_canary'] = $this->db->cell('SELECT session_canary FROM airship_users WHERE userid = ?', $userID); return $userID; }
/** * Fetch a query string from the stored queries file * * @param string $index Which index to replace * @param array $params Parameters to be replaced in the query string * @param string $cabin Which Cabin are we loading? * @param string $driver Which database driver? * @return string * @throws NotImplementedException */ function queryString(string $index, array $params = [], string $cabin = \CABIN_NAME, string $driver = '') : string { static $_cache = []; if (empty($driver)) { $db = \Airship\get_database(); $driver = $db->getDriver(); } $cacheKey = Util::hash($cabin . '/' . $driver, \Sodium\CRYPTO_GENERICHASH_BYTES_MIN); if (empty($_cache[$cacheKey])) { $driver = \preg_replace('/[^a-z]/', '', \strtolower($driver)); $path = !empty($cabin) ? ROOT . '/Cabin/' . $cabin . '/Queries/' . $driver . '.json' : ROOT . '/Engine/Queries/' . $driver . '.json'; $_cache[$cacheKey] = \Airship\loadJSON($path); } $split_key = \explode('.', $index); $v = $_cache[$cacheKey]; foreach ($split_key as $k) { if (!\array_key_exists($k, $v)) { throw new NotImplementedException(\trk('errors.database.query_not_found', $index)); } $v = $v[$k]; } if (\is_array($v)) { throw new NotImplementedException(\trk('errors.database.multiple_candidates', $index)); } $str = $v; foreach ($params as $token => $replacement) { $str = \str_replace('{{' . $token . '}}', $replacement, $str); } return $str; }
/** * Are any updates available? * * @param string $supplier * @param string $packageName * @param string $minVersion * @param string $apiEndpoint * * @return UpdateInfo[] * * @throws \Airship\Alerts\Hail\NoAPIResponse */ public function updateCheck(string $supplier = '', string $packageName = '', string $minVersion = '', string $apiEndpoint = 'version') : array { if (empty($supplier)) { $supplier = $this->supplier->getName(); } $channelsConfigured = $this->supplier->getChannels(); if (empty($channelsConfigured)) { throw new NoAPIResponse(\trk('errors.hail.no_channel_configured')); } foreach ($channelsConfigured as $channelName) { $channel = $this->getChannel($channelName); $publicKey = $channel->getPublicKey(); foreach ($channel->getAllURLs() as $ch) { try { $response = $this->hail->postSignedJSON($ch . API::get($apiEndpoint), $publicKey, ['type' => $this->type, 'supplier' => $supplier, 'package' => $packageName, 'minimum' => $minVersion]); if ($response['status'] === 'error') { $this->log($response['error'], LogLevel::ERROR, ['response' => $response, 'channel' => $ch, 'supplier' => $supplier, 'type' => $this->type, 'package' => $packageName]); continue; } $updates = []; foreach ($response['versions'] as $update) { $updates[] = new UpdateInfo($update, $ch, $publicKey, $supplier, $packageName); } if (empty($updates)) { $this->log('No updates found.', LogLevel::DEBUG, ['type' => \get_class($this), 'supplier' => $supplier, 'package' => $packageName, 'channelName' => $channelName, 'channel' => $ch]); return []; } return $this->sortUpdatesByVersion(...$updates); } catch (SignatureFailed $ex) { // Log? Definitely suppress, however. $this->log('Automatic update - signature failure. (' . \get_class($ex) . ')', LogLevel::ALERT, ['exception' => \Airship\throwableToArray($ex), 'channelName' => $channelName, 'channel' => $ch]); } catch (TransferException $ex) { // Log? Definitely suppress, however. $this->log('Automatic update failure. (' . \get_class($ex) . ')', LogLevel::WARNING, ['exception' => \Airship\throwableToArray($ex), 'channelName' => $channelName, 'channel' => $ch]); } } } throw new NoAPIResponse(\trk('errors.hail.no_channel_responded')); }
/** * Use this to change the configuration settings. * Only use this if you know what you are doing. * * @param array $options * @throws InvalidConfig */ public function reconfigure(array $options = []) { foreach ($options as $opt => $val) { switch ($opt) { case 'recycleAfter': case 'hmacIP': case 'expireOld': case 'sessionIndex': $this->{$opt} = $val; break; default: throw new InvalidConfig(\trk('errors.object.invalid_property', $opt, __CLASS__)); } } }