PHP Classes

File: src/Airship.php

Recommend this page to a friend!
  Classes of Scott Arciszewski   CMS Airship   src/Airship.php   Download  
File: src/Airship.php
Role: Example script
Content type: text/plain
Description: Example script
Class: CMS Airship
Content management system with security features
Author: By
Last change: Only strict usage of in_array()
Date: 6 years ago
Size: 24,332 bytes
 

Contents

Class file image Download
<?php declare(strict_types=1); namespace Airship; use Airship\Alerts\FileSystem\{ AccessDenied, FileNotFound }; use Airship\Alerts\Database\{ DBException, NotImplementedException }; use Airship\Engine\{ Database, State }; use ParagonIE\ConstantTime\{ Base64UrlSafe, Binary }; use ParagonIE\Halite\{ Asymmetric\Crypto, Asymmetric\SignatureSecretKey, SignatureKeyPair, Util }; use Psr\Http\Message\ResponseInterface; use ReCaptcha\ReCaptcha; use ReCaptcha\RequestMethod\CurlPost; define('AIRSHIP_VERSION', '2.0.0'); define( 'AIRSHIP_BLAKE2B_PERSONALIZATION', 'ParagonInitiativeEnterprises:Airship-PoweredByHalite:Keyggdrasil' ); define('AIRSHIP_DATE_FORMAT', 'Y-m-d\TH:i:s'); /** * Do all of these keys exist in the target array * * @param array $keys * @param array $haystack * * @return bool */ function all_keys_exist(array $keys = [], array $haystack = []): bool { $allFound = !empty($haystack) || empty($keys); foreach ($keys as $key) { $allFound = $allFound && \array_key_exists($key, $haystack); } return $allFound; } /** * Inverse of PHP's http_build_query() * * @param string $queryString * @return array */ function array_from_http_query(string $queryString = ''): array { $arr = []; \parse_str($queryString, $arr); return $arr ?? []; } /** * Diff multidimensional arrays * * @param array $new * @param array $old * @return array */ function array_multi_diff(array $new, array $old): array { $ret = []; $new_keys = \array_diff( \array_keys($new), \array_keys($old) ); foreach (\array_keys($old) as $k) { if (!isset($new['' . $k])) { // This is part of the diff $ret['' . $k] = $old[$k]; } } foreach ($new_keys as $k) { $ret['' . $k] = $new[$k]; } $diffKeys = \array_diff( \array_keys($old), \array_keys($new) ); foreach ($diffKeys as $k) { unset($ret[$k]); } $commonKeys = \array_intersect( \array_keys($new), \array_keys($old) ); foreach ($commonKeys as $key) { $ret['' . $key] = \array_diff_assoc($new[$key], $old[$key]); } return $ret; } /** * Register a PSR-4 autoloader for a given namespace and directory * * @param string $namespace * @param string $directory * @return boolean */ function autoload(string $namespace, string $directory): bool { $ds = DIRECTORY_SEPARATOR; $ns = trim($namespace, '\\'.$ds); $dir = preg_replace('#^~'.$ds.'#', ROOT.$ds, $directory); return \spl_autoload_register( function (string $class) use ($ds, $ns, $dir) { // project-specific namespace prefix $prefix = $ns . '\\'; // base directory for the namespace prefix $base_dir = $dir . $ds; // does the class use the namespace prefix? $len = Binary::safeStrlen($prefix); if (\strncmp($prefix, $class, $len) !== 0) { // no, move to the next registered autoloader return; } // get the relative class name $relative_class = Binary::safeSubstr($class, $len); // replace the namespace prefix with the base directory, replace // namespace separators with directory separators in the relative // class name, append with .php $file = $base_dir . \str_replace('\\', $ds, $relative_class) . '.php'; // if the file exists, require it if (\file_exists($file)) { require $file; } } ); } /** * A wrapper for explode($a, trim($b, $a)) * * @param $str * @param $token * @return array */ function chunk(string $str, string $token = '/'): array { return \explode( $token, \trim($str, $token) ); } /** * Clears all cached data. * * @return bool */ function clear_cache() { \clearstatcache(); if (\extension_loaded('apcu')) { \apcu_clear_cache(); } $dirs = [ 'comments', 'csp_hash', 'csp_static', 'html_purifier', 'markdown', 'static' ]; foreach ($dirs as $dir) { if (!\is_dir($dir)) { \mkdir($dir, 0775, true); continue; } foreach (\Airship\list_all_files(ROOT . '/tmp/cache/' . $dir) as $f) { if (\is_dir($f)) { continue; } if (\preg_match('#/([0-9a-z]+)$#', $f)) { \unlink($f); } } } // Nuke the Twig cache separately: foreach (\Airship\list_all_files(ROOT . '/tmp/cache/twig') as $f) { if (\is_dir($f)) { continue; } if (\preg_match('#/([0-9a-z]+).php$#', $f)) { \unlink($f); } } foreach (\glob(ROOT . '/tmp/cache/*.json') as $file) { \unlink($file); } \clearstatcache(); return true; } /** * Create a configuration writer * * @param string $rootDir * @return \Twig_Environment */ function configWriter(string $rootDir): \Twig_Environment { $twigLoader = new \Twig_Loader_Filesystem($rootDir); $twigEnv = new \Twig_Environment($twigLoader); $twigEnv->addFilter( new \Twig_SimpleFilter( 'je', function ($data, int $indents = 0) { \Airship\ViewFunctions\je( $data, $indents ); } ) ); return $twigEnv; } /** * Merge several CSP policies * * @param \array[] ...$policies * @return array */ function csp_merge(array ...$policies): array { $return = []; $n = \count($policies); for ($i = 0; $i < $n; ++$i) { foreach ($policies[$i] as $k => $data) { if (isset($return[$k])) { if ($k === 'upgrade-insecure-requests') { $return[$k] = $return[$k] || $data; continue; } elseif ($k === 'inherit') { continue; } $return[$k]['allow'] = \array_unique( \array_merge( $return[$k]['allow'] ?? [], $data['allow'] ?? [] ) ); $return[$k]['data'] = ($return[$k]['data'] ?? false) || !empty($data['data']); $return[$k]['self'] = ($return[$k]['self'] ?? false) || !empty($data['self']); $return[$k]['unsafe-inline'] = ($return[$k]['unsafe-inline'] ?? false) || !empty($data['unsafe-inline']); $return[$k]['unsafe-eval'] = ($return[$k]['unsafe-eval'] ?? false) || !empty($data['unsafe-eval']); } elseif ($k !== 'inherit') { $return[$k] = $data; } } } return $return; } /** * Expand a version string: * 5.4.19-RC1 => 5041901 * * @param string $version * @return int */ function expand_version(string $version): int { if (\preg_match('#^([0-9]+)\.([0-9]+)\.([0-9]+)(?:[^0-9]*)([0-9]+)?$#', $version, $m)) { if (!isset($m[4])) { return ( (100 * $m[3]) + (10000 * $m[2]) + (1000000 * $m[1]) ); } return ( ($m[4] - 100) + (100 * $m[3]) + (10000 * $m[2]) + (1000000 * $m[1]) ); } return 0; } /** * Get all of the parent classes that a particular class inherits from * * @param string $class - Class name * @return array */ function get_ancestors(string $class): array { $classes = [$class]; $class = \get_parent_class($class); while ($class) { if ($class[0] !== '\\') { $class = '\\' . $class; } $classes[] = $class; $class = \get_parent_class($class); } return $classes; } /** * Get the namespace of the method that called the one that called * get_caller_namespace() * * @param int $offset * @return string */ function get_caller_namespace(int $offset = 0): string { $dbg = \array_values( \array_slice( \debug_backtrace(), 1 ) ); if (!empty($dbg[$offset]['object'])) { $class = \get_class($dbg[$offset]['object']); $temp = \explode('\\', $class); \array_pop($temp); return \implode('\\', $temp); } return '\\'; } /** * Get a database class * * @static array $_cache * * @param string $id Database identifier * @return Database * * @throws DBException */ function get_database(string $id = 'default'): Database { static $_cache = []; if (isset($_cache[$id])) { return $_cache[$id]; } $state = State::instance(); if (isset($state->database_connections[$id])) { if (\count($state->database_connections[$id]) === 1) { $k = \array_keys($state->database_connections[$id])[0]; } elseif (\count($state->database_connections[$id]) > 0) { $r = \random_int( 0, \count($state->database_connections[$id]) - 1 ); $k = \array_keys($state->database_connections[$id])[$r]; } else { throw new DBException( \trk('errors.database.not_found', $id) ); } $_cache[$id] = Database::factory(...$state->database_connections[$id][$k]); return $_cache[$id]; } throw new DBException( \trk('errors.database.not_found', $id) ); } /** * Get a base URL for a gravatar image * * @param string $email * @return string */ function get_gravatar_url(string $email): string { return 'https://www.gravatar.com/avatar/' . \md5(\strtolower(\trim($email))); } /** * Get a ReCAPTCHA object configured to use * * @param string $secretKey * @param array $opts * @return ReCaptcha */ function getReCaptcha(string $secretKey, array $opts = []): ReCaptcha { $state = State::instance(); // Merge arrays: $opts = $opts + $state->universal['guzzle']; // Forcefully route this over Tor if ($state->universal['tor-only']) { $opts[CURLOPT_PROXY] = 'http://127.0.0.1:9050/'; $opts[CURLOPT_PROXYTYPE] = CURLPROXY_SOCKS5; } $curlPost = new CurlPost(null, $opts); return new ReCaptcha($secretKey, $curlPost); } /** * @param string $mimeType * * @return array<string, string> */ function get_standard_headers(string $mimeType = 'text/html;charset=UTF-8'): array { $state = State::instance(); return [ 'Content-Type' => $mimeType, 'Content-Language' => $state->lang, 'X-Content-Type-Options' => 'nosniff', 'X-Frame-Options' => 'SAMEORIGIN', 'X-XSS-Protection' => '1; mode=block' ]; } /** * Is a particular function disabled? * * @param string $function * @return boolean */ function is_disabled(string $function): bool { static $disabled = null; if ($disabled === null) { $disabled = \explode(',', \ini_get('disable_functions')); } return \in_array($function, $disabled, true); } /** * Is this URL a Tor Hidden Service? * * @param string $url * @return bool */ function isOnionUrl(string $url): bool { $host = \parse_url($url, \PHP_URL_HOST); if ($host !== null) { if (Binary::safeStrlen($host) < 7) { return false; } $suffix = Binary::safeSubstr($host, -6); return \strtolower($suffix) === '.onion'; } return false; } /** * Output a JSON response, terminate script execution. * * @param mixed $result * @param SignatureSecretKey $signingKey Optional - used for API responses. */ function json_response($result, $signingKey = null) { if (!\headers_sent()) { foreach (get_standard_headers('application/json') as $left => $right) { \header($left . ': ' . $right); } } if ($signingKey instanceof SignatureSecretKey || $signingKey instanceof SignatureKeyPair) { if ($signingKey instanceof SignatureKeyPair) { // We don't need the whole keypair. $signingKey = $signingKey->getSecretKey(); } $message = \json_encode($result, JSON_PRETTY_PRINT); $signature = Crypto::sign( $message, $signingKey ); unset($signingKey); die( $signature . "\n" . $message ); } // Otherwise, we're just dumping the message verbatim: die( \json_encode($result, JSON_PRETTY_PRINT) ); } /** * Return a subset of the keys of the source array * * @param array $source * @param array $keys * @return array */ function keySlice(array $source, array $keys = []): array { return \array_intersect_key( $source, \array_flip( \array_values($keys) ) ); } /** * List all the files in a directory (and subdirectories) * * @param string $folder - start searching here * @param string $extension - extensions to match * * @return array */ function list_all_files(string $folder, string $extension = '*'): array { if (!\is_dir($folder)) { return []; } $dir = new \RecursiveDirectoryIterator($folder); $ite = new \RecursiveIteratorIterator($dir); if ($extension === '*') { $pattern = '/.*/'; } else { $pattern = '/.*\.' . \preg_quote($extension, '/') . '$/'; } $files = new \RegexIterator($ite, $pattern, \RegexIterator::GET_MATCH); $fileList = []; foreach($files as $file) { if (\is_array($file)) { foreach ($file as $i => $f) { // Prevent . and .. from being treated as valid files: $check = \preg_replace('#^(.+?)/([^/]+)$#', '$2', $f); if ($check === '.' || $check === '..') { unset($file[$i]); } } } $fileList = \array_merge($fileList, $file); } return $fileList; } /** * Load a JSON file and parses it * * @param string $file - The absolute path of the file name * @return mixed * @throws AccessDenied * @throws FileNotFound */ function loadJSON(string $file) { // Very specific checks if (!\file_exists($file)) { throw new FileNotFound($file); } if (!\is_readable($file)) { throw new AccessDenied($file); } // The meat of this function is kind of boring: return \Airship\parseJSON( \file_get_contents($file), true ); } /** * Parser for JSON with comments * * @param string $json JSON text * @param boolean $assoc Return as an associative array? * @param int $depth Maximum depth * @param int $options options * @return mixed */ function parseJSON( string $json, bool $assoc = false, int $depth = 512, int $options = 0 ) { return \json_decode( \preg_replace( '#(/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*+/)|([\s\t]//.*)|(^//.*)#', '', $json ), $assoc, $depth, $options ); } /** * Force the schema to begin with HTTPS * * @param string $url * * @return string * @throws \Exception */ function makeHTTPS(string $url): string { $pos = \strpos($url, '://'); if (!\is_int($pos)) { throw new \Exception('A malformed URL was passed to \\Airship\\makeHTTPS()'); } /** @var array<string, string> $pieces */ $pieces = \parse_url($url); if (!\is_array($pieces)) { throw new \Exception('A malformed URL was passed to \\Airship\\makeHTTPS()'); } switch ($pieces['scheme']) { case 'http': case 'https': $scheme = 'https'; break; case 'ws': case 'wss': $scheme = 'wss'; break; default: throw new \Exception('Disallowed scheme'); } return $scheme . '://' . Binary::safeSubstr($url, $pos + 3); } /** * Given a file path, only return the file name. Optionally, trim the * extension. * * @param string $fullPath * @param bool $trimExtension * @return string * @throws \Error */ function path_to_filename(string $fullPath, bool $trimExtension = false): string { $pieces = \Airship\chunk($fullPath); $lastPiece = \array_pop($pieces); if (!\is_string($lastPiece)) { throw new \Error('Invalid file name'); } if ($trimExtension) { $parts = \Airship\chunk($lastPiece, '.'); \array_pop($parts); return \implode('.', $parts); } return $lastPiece; } /** * Get an object's real type. * * @param mixed $mixed The variable being evaluated. Can be a scalar type or * an object. * @param bool $ancestors Also include information about this object's parent * classes? * * @return string */ function get_var_type($mixed = null, bool $ancestors = false): string { if (\func_num_args() === 0) { return 'void'; } $type = \strtolower(\gettype($mixed)); if ($type === 'integer') { return 'int'; } if ($type === 'boolean') { return 'bool'; } if ($type === 'double') { return 'float'; } if ($type === 'object') { $class = \get_class($mixed); if ($ancestors) { $lineage = get_ancestors($class); \array_shift($lineage); if (empty($lineage)) { $type .= ' (' . $class . ', -- no parents --)'; } else { $type .= ' (' . $class . ', ' . \json_encode($lineage) . ')'; } } else { $type .= ' (' . $class . ')'; } } return $type; } /** * Redirect the user to a given URL. Optionally pass GET parameters. * * @param string $destination The URL to redirect the user to * @param array $params - GET parameters * @return void */ function redirect( string $destination, array $params = [] ) { if (empty($destination)) { $destination = '/'; } if (empty($params)) { \header('Location: '.$destination); } else { \header('Location: '.$destination.'?'.\http_build_query($params)); } exit; } /** * 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; } /** * Fetch a query string from the stored queries file * * @param string $index Which index to replace * @param string $driver Which database driver * @param array $params Parameters to be replaced in the query string? * @return string * @throws NotImplementedException */ function queryStringRoot( string $index, string $driver = '', array $params = [] ): string { return \Airship\queryString( $index, $params, '', $driver ); } /** * Save a JSON file * * @param string $file - The absolute path of the file name * @param mixed $data * @return bool * @throws AccessDenied */ function saveJSON(string $file, $data = null): bool { if (\file_exists($file) && !\is_writable($file)) { throw new AccessDenied($file); } elseif (!\file_exists($file) && !\is_writable(\dirname($file))) { throw new AccessDenied(\dirname($file)); } return \file_put_contents( $file, \json_encode($data, JSON_PRETTY_PRINT) ) !== false; } /** * @param ResponseInterface $response */ function sendHeaders(ResponseInterface $response): void { sendHeadersArray($response->getHeaders()); } /** * @param ResponseInterface $response */ function sendHeadersArray(array $headers): void { foreach ($headers as $name => $values) { if (\is_array($values)) { foreach ($values as $value) { \header(\sprintf('%s: %s', $name, $value), false); } } elseif (\is_string($values)) { \header(\sprintf('%s: %s', $name, $values), false); } } } /** * Shuffle an array using a CSPRNG * * @link https://paragonie.com/b/JvICXzh_jhLyt4y3 * * @param array &$array reference to an array * @return void * @throws \Throwable */ function secure_shuffle(array &$array): void { $size = \count($array); $keys = \array_keys($array); for ($i = $size - 1; $i > 0; --$i) { $r = \random_int(0, $i); if ($r !== $i) { $temp = $array[$keys[$r]]; $array[$keys[$r]] = $array[$keys[$i]]; $array[$keys[$i]] = $temp; } } // Reset indices: $array = array_values($array); } /** * Determine the valid slug for a given title, before de-duplication * * @param string $title * @return string */ function slugFromTitle(string $title): string { $slug = \preg_replace( '#[^A-Za-z0-9]#', '-', \strtolower($title) ); return \trim( \preg_replace( '#\-{2,}#', '-', $slug ), '-' ); } /** * Like PHP's `tempnam()` but allows you to specify the file extension. * * @param string $prefix Prefix * @param string $ext File extension * @param string $dir Which directory? * @return string */ function tempnam( string $prefix = 'airship-', string $ext = '', string $dir = '' ): string { if (empty($dir)) { $dir = \sys_get_temp_dir(); } $temp = \tempnam($dir, $prefix); \unlink($temp); return $temp . '.' . $ext; } /** * Convert an Exception or Error into an array (for logging) * * @param \Throwable $ex * @return array */ function throwableToArray(\Throwable $ex): array { $prev = $ex->getPrevious(); return [ 'line' => $ex->getLine(), 'file' => $ex->getFile(), 'message' => $ex->getMessage(), 'code' => $ex->getCode(), 'trace' => $ex->getTrace(), 'previous' => $prev ? throwableToArray($prev) : null ]; } /** * Invoke all of the tighten[BoltNameGoesHere]Bolt() methods automatically * * @param object $obj */ function tightenBolts($obj) { $class = \get_class($obj); foreach (\get_class_methods($class) as $method) { if (\preg_match('/^tighten([A-Za-z0-9_]*)Bolt$/', $method)) { $obj->$method(); } } } /** * Create a unique ID (e.g. for permalinks) * * @param int $length * @return string */ function uniqueId(int $length = 24): string { if ($length < 1) { return ''; } $n = (int) ceil($length * 0.75); $str = \random_bytes($n); return Binary::safeSubstr( Base64UrlSafe::encode($str), 0, $length ); }