PHP Classes
elePHPant
Icontem

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: 7 days 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
    );
}