<?php
namespace XFramework;

/**
 * Roundcube Plus Framework plugin.
 *
 * Copyright 2017, Tecorama LLC.
 *
 * @license Commercial. See the LICENSE file for details.
 */

require_once(__DIR__ . "/../vendor/autoload.php");

class Utils
{
    /**
     * Returns the current user IP. This function takes into account the config variable remote_addr_key, which can be
     * used to change the key used to retrieve the user IP from the $_SERVER variable.
     *
     * @return string
     */
    static public function getRemoteAddr(): string
    {
        $key = xrc()->config->get("remote_addr_key");

        if (!empty($_SERVER[$key])) {
            return $_SERVER[$key];
        }

        if (!empty($_SERVER['REMOTE_ADDR'])) {
            return $_SERVER['REMOTE_ADDR'];
        }

        return "";
    }

    /**
     * Converts an integer to a human-readable file size string.
     *
     * @param mixed $size
     * @return string
     */
    static public function sizeToString($size): string
    {
        if (!is_numeric($size)) {
            return "-";
        }

        $units = ["B", "kB", "MB", "GB", "TB", "PB"];
        $index = 0;

        while ($size >= 1000) {
            $size /= 1000;
            $index++;
        }

        return round($size) . " " . $units[$index];
    }

    /**
     * Shortens a string to the specified length and appends (...). If the string is shorter than the specified length,
     * the string will be left intact.
     *
     * @param string $string
     * @param int $length
     * @return string
     */
    public static function shortenString(string $string, int $length = 50): string
    {
        $string = trim($string);

        if (strlen($string) <= $length) {
            return $string;
        }

        $string = substr($string, 0, $length);

        if ($i = strrpos($string, " ")) {
            $string = substr($string, 0, $i);
        }

        return $string . "...";
    }

    /**
     * Returns a string containing a relative path for saving files based on the passed id. This is used for limiting
     * the amount of files stored in a single directory.
     *
     * @param string|int $id
     * @param int $idsPerDir
     * @param int $levels
     * @return string
     */
    public static function structuredDirectory($id, int $idsPerDir = 500, int $levels = 2): string
    {
        if ($idsPerDir <= 0) {
            $idsPerDir = 100;
        }

        if ($levels < 1 || $levels > 3) {
            $levels = 2;
        }

        $level1 = floor($id / $idsPerDir);
        $level2 = floor($level1 / 1000);
        $level3 = floor($level2 / 1000);

        return ($levels > 2 ? sprintf("%03d", $level3 % 1000) . "/" : "") .
            ($levels > 1 ? sprintf("%03d", $level2 % 1000) . "/" : "") .
            sprintf("%03d", $level1 % 1000) . "/";
    }


    /**
     * Returns a string that is sure to be a valid file name.
     *
     * @param string $string
     * @return string
     */
    public static function ensureFileName(string $string): string
    {
        $result = preg_replace("/[\/\\\:?*+%|\"<>]/i", "_", strtolower($string));
        $result = trim(preg_replace("([_]{2,})", "_", $result), "_ \t\n\r\0\x0B");
        return $result ?: "unknown";
    }

    /**
     * Returns a unique file name. This function generates a random name, then checks if the file with this name already
     * exists in the specified directory. If it does, it generates a new random file name.
     *
     * @param string $path
     * @param string $ext
     * @param string $prefix
     * @return string
     */
    public static function uniqueFileName(string $path, string $ext = "", string $prefix = ""): string
    {
        if (strlen($ext) && $ext[0] != ".") {
            $ext = "." . $ext;
        }

        $path = self::addSlash($path);

        do {
            $fileName = uniqid($prefix, true) . $ext;
        } while (file_exists($path . $fileName));

        return $fileName;
    }

    /**
     * Extracts the extension from file name.
     *
     * @param string $fileName
     * @return string
     */
    public static function ext(string $fileName): string
    {
        return strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
    }

    /**
     * Creates an empty directory with write permissions. It returns true if the directory already exists and is
     * writable. Also, if umask is set, mkdir won't create the directory with 0777 permissions, for example, if umask
     * is 0022, the outcome will be 0777-0022 = 0755, so we reset umask before creating the directory.
     *
     * @param string $dir
     * @return boolean
     */
	public static function makeDir(string $dir): bool
    {
		if (file_exists($dir)) {
            return is_writable($dir);
        }

		$umask = umask(0);
		$result = @mkdir($dir, 0777, true);
		umask($umask);

		return $result;
	}

    /**
     * Recursively removes a directory (including all the hidden files.)
     *
     * @param string $dir
     * @param bool $followLinks Should we follow directory links?
     * @param bool $contentsOnly Removes contents only leaving the directory itself intact.
     * @return boolean
     */
    public static function removeDir(string $dir, bool $followLinks = false, bool $contentsOnly = false): bool
    {
        if (empty($dir) || !is_dir($dir)) {
            return true;
        }

        $dir = self::addSlash($dir);
        $files = array_diff(scandir($dir), [".", ".."]);

        foreach ($files as $file) {
            if (is_dir($dir . $file)) {
                self::removeDir($dir . $file, $followLinks);
                continue;
            }

            if (is_link($dir . $file) && $followLinks) {
                unlink(readlink($dir . $file));
            }

            unlink($dir . $file);
        }

        return $contentsOnly || rmdir($dir);
    }

    /**
     * Returns the current url. Optionally it appends a path specified by the $path parameter.
     *
     * @param string $path
     * @return string|boolean
     * @codeCoverageIgnore
     */
	public static function getUrl($path = false, $hostOnly = false, $cut = false)
	{
        // if absolute path specified, simply return it
        if (strpos($path, "://")) {
            return $path;
        }

        // check if an overwrite url specified in the config
        // (rcmail might or might not exist, for example, during some caldav requests it doesn't)
        if (class_exists("rcmail")) {
            $overwriteUrl = xrc()->config->get("overwrite_roundcube_url");
        } else {
            $overwriteUrl = false;
        }

        $requestUri = empty($_SERVER['REQUEST_URI']) ? "_" : $_SERVER['REQUEST_URI'];
        $parts = parse_url(empty($overwriteUrl) ? $requestUri : $overwriteUrl);
        $urlPath = empty($parts['path']) ? "" : $parts['path'];

        if (!empty($parts['scheme'])) {
            $scheme = strtolower($parts['scheme']) == "https" ? "https" : "http";
        } else {
            $scheme = empty($_SERVER["HTTPS"]) || $_SERVER["HTTPS"] != "on" ? "http" : "https";
        }

        if (!empty($parts['host'])) {
            $host = $parts['host'];
        } else {
            $host = empty($_SERVER['HTTP_HOST']) ? false : $_SERVER['HTTP_HOST'];

            if (empty($host)) {
                $host = empty($_SERVER['SERVER_NAME']) ? false : $_SERVER['SERVER_NAME'];
            }
        }

        if (!empty($parts['port'])) {
            $port = $parts['port'];
        } else {
            $port = empty($_SERVER['SERVER_PORT']) ? "80" : $_SERVER['SERVER_PORT'];
        }

        // if url not specified in the config, check for proxy values
        if (empty($overwriteUrl)) {
            empty($_SERVER['HTTP_X_FORWARDED_PROTO']) || ($scheme = $_SERVER['HTTP_X_FORWARDED_PROTO']);
            empty($_SERVER['HTTP_X_FORWARDED_HOST']) || ($host = $_SERVER['HTTP_X_FORWARDED_HOST']);
            empty($_SERVER['HTTP_X_FORWARDED_PORT']) || ($port = $_SERVER['HTTP_X_FORWARDED_PORT']);
        }

        // if full url specified but without the protocol, prepend http or https and return.
        // we can't just leave it as is because roundcube will prepend the current domain
        if (strpos($path, "//") === 0) {
            return $scheme . ":" . $path;
        }

        // we have to have the host
        if (empty($host)) {
            return false;
        }

        // if we need the host only, return it
        if ($hostOnly) {
            return $host;
        }

        // format port
        if ($port && is_numeric($port) && $port != "443" && $port != "80") {
            $port = ":" . $port;
        } else {
            $port = "";
        }

        // in cpanel $urlPath will have index.php at the end
        if (substr($urlPath, -4) == ".php") {
            $urlPath = dirname($urlPath);
        }

        // if path begins with a slash, cut it
        if (strpos($path, "/") === 0) {
            $path = substr($path, 1);
        }

        $result = self::addSlash($scheme . "://" . $host . $port . $urlPath);

        // if paths to cut were specified, find and cut the resulting url
        if ($cut) {
            if (!is_array($cut)) {
                $cut = [$cut];
            }

            foreach ($cut as $val) {
                if (($pos = strpos($result, $val)) !== false) {
                    $result = substr($result, 0, $pos);
                }
            }
        }

        return $result . $path;
	}

    /**
     * Returns true if the program runs under cPanel.
     *
     * @return bool
     * @codeCoverageIgnore
     */
    public static function isCpanel(): bool
    {
        return strpos(self::getUrl(), "/cpsess") !== false;
    }

    /**
     * Removes the slash from the end of a string.
     *
     * @param $string
     * @return string
     */
	public static function removeSlash($string): string
    {
        $string = (string)$string;
		return substr($string, -1) == '/' || substr($string, -1) == '\\' ? substr($string, 0, -1) : $string;
	}

    /**
     * Adds a slash to the end of the string.
     *
     * @param $string
     * @return string
     */
	public static function addSlash($string): string
    {
        $string = (string)$string;
		return substr($string, -1) == '/' || substr($string, -1) == '\\' ? $string : $string . '/';
	}

    /**
     * Creates a random token composed of lower case letters and numbers.
     *
     * @param int $length
     * @return string
     */
    public static function randomToken(int $length = 32): string
    {
        $characters = "abcdefghijklmnopqrstuvwxyz1234567890";
        $charactersLength = strlen($characters);
        $result = "";

        for ($i = 0; $i < $length; $i++) {
            $result .= $characters[mt_rand(0, $charactersLength - 1)];
        }

        return $result;
    }

    /**
     * Encodes an integer id using Roundcube's desk key and returns hex string.
     *
     * @param int|string $id
     * @return string
     */
    public static function encodeId($id): string
    {
        return dechex(crc32(xrc()->config->get("des_key")) + $id);
    }

    /**
     * Decodes an id encoded using encodeId()
     *
     * @param string $encodedId
     * @return int
     */
    public static function decodeId(string $encodedId): int
    {
        return hexdec($encodedId) - crc32(xrc()->config->get("des_key"));
    }

    public static function exit404()
    {
        header("HTTP/1.0 404 Not Found");
        exit();
    }

    /**
     * Creates a string that contains encrypted information about an action and its associated data. This function can
     * be used to create strings in the url that are masked from the users.
     *
     * @param string $action
     * @param $data
     * @return string
     */
    public static function encodeUrlAction(string $action, $data): string
    {
        return rtrim(
            strtr(
                base64_encode(
                    xrc()->encrypt(
                        json_encode(
                            ["action" => $action, "data" => $data]
                        ),
                        "des_key",
                        false)
                ),
                "+/",
                "-_"
            ),
            "="
        );
    }

    /**
     * Decodes a string encoded with encodeUrlAction()
     *
     * @param string $encoded
     * @param $data
     * @return string|boolean
     */
    public static function decodeUrlAction(string $encoded, &$data)
    {
        $array = json_decode(xrc()->decrypt(
            base64_decode(str_pad(strtr($encoded, "-_", "+/"), strlen($encoded) % 4, "=", STR_PAD_RIGHT)),
            "des_key",
            false
        ), true);

        if (is_array($array) && array_key_exists("action", $array) && array_key_exists("data", $array)) {
            $data = $array['data'];
            return $array['action'];
        }

        return false;
    }

    /**
     * Packs data into a compressed, encoded format.
     *
     * @param array $data
     * @return bool|string
     */
    public static function pack(array $data)
    {
        $l = $data['lc'];
        $data = json_encode($data);
        $iv = openssl_random_pseudo_bytes(16, $ret);
        $akey = "4938" . openssl_random_pseudo_bytes(32, $ret);
        $header = "687474703a2f2f616e616c79746963732e726f756e6463756265706c75732e636f6d3f713d";
        $pkey = self::decodeBinary("LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNS".
            "UlCQ2dLQ0FRRUF5YkQ3enovUHlPdy9yUEdQK2o3MQpkWUhPUDJuRjhKRUYycGtZTXZWYVdaam1XWUR1ZCsrYU1JMXkvTEJZRXZMaVJte".
            "jh4NFBoTkZaMW9tenBrU0sxCjBUdWp2L2lTcDY3V3lDcjR2d2Y2eWVLMTdrbm5LOVovQXBtcE5CM09kQ3RRVFVEck80aDNWZTArMUVYQ".
            "TR4ZkQKQjBrVnAyNVJQYmw2ZHdaMytjQlh4OHZ0cDhwNUlmTEZ0ODZvVHEydzZBeUQvUGU5Y1pkcENpcUU2K0FwU0tLWgpRKzFQNXdod".
            "0hkcnYxNlJhVWtqR0NpNjkrNkpVYzdDajQwNDJjNng4ZnFTY0xpcDN2VmI0ZmRpMUUyVXZOZVVSCnhZdklLbml5a1lnMWczMitRdjJ1c".
            "Dc4THlVdmlleVh2TlJYcnZXdS9obXlQeFpkMjVYYUVLK1V4ZFNLNy9hNWYKUlFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0t");

        // @codeCoverageIgnoreStart
        if (function_exists("gzcompress") && strlen($data) > 64) {
            $data = gzcompress($data, 9);
            $comp = "1";
        } else {
            $comp = "0";
        }

        if (!openssl_public_encrypt($akey, $kb, $pkey) ||
            !($ekey = self::encodeBinary($kb)) ||
            !($db = openssl_encrypt("5791" . $data, "AES-256-CBC", $akey, 1, $iv)) ||
            !($edb = self::encodeBinary($db))
        ) {
            return false;
        }
        // @codeCoverageIgnoreEnd

        return pack("H*", $header) . "6472" . $comp . bin2hex(pack("S", strlen($ekey))) .
            bin2hex(pack("S", strlen($edb))) . bin2hex($iv) . $ekey . $edb . "&l=$l";
    }

    /**
     * Encodes binary data using base64.
     *
     * @param $data
     * @return string
     */
    public static function encodeBinary($data): string
    {
        return urlencode(rtrim(strtr(base64_encode($data), "+/", "-_"), "="));
    }

    /**
     * Decodes base64-encoded binary string.
     *
     * @param $data
     * @return bool|string
     */
    public static function decodeBinary($data)
    {
        return base64_decode(str_pad(strtr($data, "-_", "+/"), strlen($data) % 4, "=", STR_PAD_RIGHT));
    }

    /**
     * Loads the specified config file and returns the array of config options.
     *
     * @param string $configFile
     * @return array
     */
    public static function loadConfigFile(string $configFile): array
    {
        $config = [];

        if (file_exists($configFile)) {
            include($configFile);
        }

        return $config;
    }

    /**
     * Logs a message in the Roundcube error log or system error file.
     *
     * @param string $error
     * @return bool
     * @codeCoverageIgnore
     */
    public static function logError(string $error): bool
    {
        $bt = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 2);
        $info = "";

        isset($bt[1]['class']) && ($info .= $bt[1]['class'] . "::");
        isset($bt[1]['function']) && ($info .= $bt[1]['function']);
        isset($bt[1]['line']) && ($info .= " " . $bt[1]['line']);
        $info = trim($info);

        $error = "$error [RC+" . ($info ? " $info" : "")  . "]";

        if (class_exists("\\rcube") && @\rcube::write_log('errors', $error)) {
            return true;
        }

        return error_log($error);
    }

    /**
     * Logs a message in a custom log file. This method doesn't depend on the presence of the RC log methods.
     *
     * @param string $text
     * @param string $file
     * @return bool|int
     */
    public static function xlog(string $text, string $file = "xlog")
    {
        return file_put_contents(
            rtrim(RCUBE_INSTALL_PATH, "/") . "/logs/$file",
            date("[Y-m-d H:i:s] ") . $text . "\n",
            FILE_APPEND
        );
    }

    /**
     * Removes parameters from the url and returns the modified url.
     *
     * @param array|string $variables
     * @param string|bool $url If not specified, the current url will be used.
     * @return string
     */
    public static function removeVarsFromUrl($variables, $url = false)
    {
        $url || ($url = self::getUrl() . "?" . $_SERVER['QUERY_STRING']);
        $queryStart = strpos($url, "?");

        if (!$variables || $queryStart === false) {
            return $url;
        }

        if (!is_array($variables)) {
            $variables = [$variables];
        }

        parse_str(parse_url($url, PHP_URL_QUERY), $array);

        foreach ($variables as $val) {
            unset($array[$val]);
        }

        $query = http_build_query($array);

        return substr($url, 0, $queryStart) . ($query ? "?" . $query : "");
    }

    /**
     * Adds parameters to the url and returns the modified url.
     *
     * @param array $variables
     * @param string|bool $url If not specified, the current url will be used.
     * @return string
     */
    public static function addVarsToUrl(array $variables, $url = false)
    {
        $url || ($url = self::getUrl() . "?" . $_SERVER['QUERY_STRING']);

        if (empty($variables)) {
            return $url;
        }

        parse_str(parse_url($url, PHP_URL_QUERY), $array);

        foreach ($variables as $key => $val) {
            $array[$key] = $val;
        }

        if (($i = strpos($url, "?"))) {
            $url = substr($url, 0, $i);
        }

        return $url . "?" . http_build_query($array);
    }

    /**
     * Gets contents from the specified source.
     * @param string $source
     * @return array
     */
    public static function getContents(string $source): array
    {
        if (function_exists("curl_init") && ($curl = curl_init($source))) {
            curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($curl, CURLOPT_TIMEOUT, 10);
            curl_setopt($curl, CURLOPT_RANGE, "0-1023");
            $result = curl_exec($curl);
            curl_close($curl);
        } else {
            $result = @file_get_contents($source, 0, stream_context_create(["http" => ["timeout" => 10]]), 0, 1024);
        }

        return ($result = trim($result)) && ($data = @json_decode($result, true)) ? $data : [];
    }

    /**
     * Returns a string token of a given length.
     * @param int $length
     * @return mixed
     */
    public static function getToken(int $length = 31)
    {
        return \rcube_utils::random_bytes($length);
    }

    /**
     * Generates UUID v4.
     * @return string
     */
    public static function uuid(): string
    {
        try {
            $data = function_exists("random_bytes") ? random_bytes(16) : openssl_random_pseudo_bytes(16);

            if (strlen($data) != 16) {
                throw new \Exception();
            }

            $data[6] = chr(ord($data[6]) & 0x0f | 0x40); // set version to 0100
            $data[8] = chr(ord($data[8]) & 0x3f | 0x80); // set bits 6-7 to 10

            return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
        } catch (\Exception $e) {
            return sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
                mt_rand(0, 0xffff), mt_rand(0, 0xffff),
                mt_rand(0, 0xffff),
                mt_rand(0, 0x0fff) | 0x4000,
                mt_rand(0, 0x3fff) | 0x8000,
                mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
            );
        }
    }

    /**
     * Sanitizes the html by removing all the tags and attributes that are not specified in $allowedTagsAndAttributes.
     * @param string $html
     * @param array $allowedTagsAndAttributes
     * @return array|string|string[]|null
     */
    public static function sanitizeHtml(string $html, array $allowedTagsAndAttributes = [])
    {
        if (empty($allowedTagsAndAttributes)) {
            $allowedTagsAndAttributes = [
                "p" => [],
                "strong" => [],
                "em" => [],
                "img" => ["src", "alt", "title"],
                "a" => ["href", "title"],
            ];
        }

        $dom = new \DOMDocument();

        // add the xml tag to specify the utf-8 encoding, otherwise the non-english characters will not be rendered properly
        if ($dom->loadHTML(
            '<?xml version="1.0" encoding="utf-8">' . strip_tags($html, array_keys($allowedTagsAndAttributes)),
            LIBXML_NOBLANKS | LIBXML_NOERROR | LIBXML_NOWARNING)
        ) {
            foreach ($allowedTagsAndAttributes as $tag => $attributes) {
                foreach ($dom->getElementsByTagName($tag) as $element) {
                    // keep a copy of the element, so we can re-insert its allowed attributes
                    $clone = clone $element;
                    // remove all the attributes from the element
                    while ($element->hasAttributes()) {
                        $element->removeAttributeNode($element->attributes->item(0));
                    }
                    // add only the allowed attributes to the element
                    foreach ($attributes as $attr) {
                        if ($value = $clone->getAttribute($attr)) {
                            $element->setAttribute($attr, $value);
                        }
                    }
                    // add the target attribute to a
                    if ($tag == "a") {
                        $element->setAttribute("target", "_blank");
                    }
                }
            }
        }

        // remove the xml tag we added and the doctype/html/body added by DOMDocument
        return preg_replace("#<(?:!DOCTYPE|\?xml|/?html|/?body)[^>]*>\s*#i", "", $dom->saveHTML());
    }

    /**
     * Checks if the IP matches the specified IP or range. Works with IPv4 and IPv6.
     * @param string $ip - IPv4 or IPv6
     * @param string $range - IPv4 or IPv6, or range: IP with CIDR or in the format IP - IP
     * @return bool
     */
    public static function ipMatch(string $ip, string $range): bool
    {
        try {
            return \IPTools\Range::parse(trim(str_replace(" - ", "-", $range)))->contains(new \IPTools\IP(trim($ip)));
        } catch (\Exception $e) {
            return false;
        }
    }

    /**
     * Returns all the emails of the current user: the login email and all the identity emails.
     * @return array
     */
    public static function getUserEmails(): array
    {
        $rcmail = xrc();
        $result = [];

        if (is_object($user = $rcmail->user)) {
            if (filter_var($email = $rcmail->get_user_email(), FILTER_VALIDATE_EMAIL)) {
                $result[] = $email;
            }

            foreach ($user->list_identities() as $identity) {
                if (!in_array($identity['email'], $result) && filter_var($identity['email'], FILTER_VALIDATE_EMAIL)) {
                    $result[] = $identity['email'];
                }
            }
        }

        return $result;
    }

}