<?php

/**
 * DokuWiki IP address functions.
 *
 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
 * @author     Zebra North <mrzebra@mrzebra.co.uk>
 */

namespace dokuwiki;

use dokuwiki\Input\Input;
use Exception;

class Ip
{
    /**
     * Determine whether an IP address is within a given CIDR range.
     * The needle and haystack may be either IPv4 or IPv6.
     *
     * Example:
     *
     * ipInRange('192.168.11.123', '192.168.0.0/16') === true
     * ipInRange('192.168.11.123', '::192.168.0.0/80') === true
     * ipInRange('::192.168.11.123', '192.168.0.0/16') === true
     * ipInRange('::192.168.11.123', '::192.168.0.0/80') === true
     *
     * @param string $needle The IP to test, either IPv4 in dotted decimal
     *                         notation or IPv6 in colon notation.
     * @param string $haystack The CIDR range as an IP followed by a forward
     *                         slash and the number of significant bits.
     *
     * @return bool Returns true if $needle is within the range specified
     *              by $haystack, false if it is outside the range.
     *
     * @throws Exception Thrown if $needle is not a valid IP address.
     * @throws Exception Thrown if $haystack is not a valid IP range.
     */
    public static function ipInRange(string $needle, string $haystack): bool
    {
        $range = explode('/', $haystack);
        $networkIp = Ip::ipToNumber($range[0]);
        $maskLength = $range[1];

        // For an IPv4 address the top 96 bits must be zero.
        if ($networkIp['version'] === 4) {
            $maskLength += 96;
        }

        if ($maskLength > 128) {
            throw new Exception('Invalid IP range mask: ' . $haystack);
        }

        $maskLengthUpper = min($maskLength, 64);
        $maskLengthLower = max(0, $maskLength - 64);

        $maskUpper = ~0 << intval(64 - $maskLengthUpper);
        $maskLower = ~0 << intval(64 - $maskLengthLower);

        $needle = Ip::ipToNumber($needle);

        return ($needle['upper'] & $maskUpper) === ($networkIp['upper'] & $maskUpper) &&
            ($needle['lower'] & $maskLower) === ($networkIp['lower'] & $maskLower);
    }

    /**
     * Convert an IP address from a string to a number.
     *
     * This splits 128 bit IP addresses into the upper and lower 64 bits, and
     * also returns whether the IP given was IPv4 or IPv6.
     *
     * The returned array contains:
     *
     *  - version: Either '4' or '6'.
     *  - upper: The upper 64 bits of the IP.
     *  - lower: The lower 64 bits of the IP.
     *
     * For an IPv4 address, 'upper' will always be zero.
     *
     * @param string $ip The IPv4 or IPv6 address.
     *
     * @return int[] Returns an array of 'version', 'upper', 'lower'.
     *
     * @throws Exception Thrown if the IP is not valid.
     */
    public static function ipToNumber(string $ip): array
    {
        $binary = inet_pton($ip);

        if ($binary === false) {
            throw new Exception('Invalid IP: ' . $ip);
        }

        if (strlen($binary) === 4) {
            // IPv4.
            return [
                'version' => 4,
                'upper' => 0,
                'lower' => unpack('Nip', $binary)['ip'],
            ];
        } else {
            // IPv6.
            $result = unpack('Jupper/Jlower', $binary);
            $result['version'] = 6;
            return $result;
        }
    }

    /**
     * Determine if an IP address is equal to another IP or within an IP range.
     * IPv4 and IPv6 are supported.
     *
     * @param string $ip The address to test.
     * @param string $ipOrRange An IP address or CIDR range.
     *
     * @return bool Returns true if the IP matches, false if not.
     */
    public static function ipMatches(string $ip, string $ipOrRange): bool
    {
        try {
            // If it's not a range, compare the addresses directly.
            // Addresses are converted to numbers because the same address may be
            // represented by different strings, e.g. "::1" and "::0001".
            if (strpos($ipOrRange, '/') === false) {
                return Ip::ipToNumber($ip) === Ip::ipToNumber($ipOrRange);
            }

            return Ip::ipInRange($ip, $ipOrRange);
        } catch (Exception $ex) {
            // The IP address was invalid.
            return false;
        }
    }

    /**
     * Given the IP address of a proxy server, determine whether it is
     * a known and trusted server.
     *
     * This test is performed using the config value `trustedproxies`.
     *
     * @param string $ip The IP address of the proxy.
     *
     * @return bool Returns true if the IP is trusted as a proxy.
     */
    public static function proxyIsTrusted(string $ip): bool
    {
        global $conf;

        // If the configuration is empty then no proxies are trusted.
        if (empty($conf['trustedproxies'])) {
            return false;
        }

        foreach ((array)$conf['trustedproxies'] as $trusted) {
            if (Ip::ipMatches($ip, $trusted)) {
                return true; // The given IP matches one of the trusted proxies.
            }
        }

        return false; // none of the proxies matched
    }

    /**
     * Get the originating IP address and the address of every proxy that the
     * request has passed through, according to the X-Forwarded-For header.
     *
     * To prevent spoofing of the client IP, every proxy listed in the
     * X-Forwarded-For header must be trusted, as well as the TCP/IP endpoint
     * from which the connection was received (i.e. the final proxy).
     *
     * If the header is not present or contains an untrusted proxy then
     * an empty array is returned.
     *
     * The client IP is the first entry in the returned list, followed by the
     * proxies.
     *
     * @return string[] Returns an array of IP addresses.
     */
    public static function forwardedFor(): array
    {
        /* @var Input $INPUT */
        global $INPUT, $conf;

        $forwardedFor = $INPUT->server->str('HTTP_X_FORWARDED_FOR');

        if (empty($conf['trustedproxies']) || !$forwardedFor) {
            return [];
        }

        // This is the address from which the header was received.
        $remoteAddr = $INPUT->server->str('REMOTE_ADDR');

        // Get the client address from the X-Forwarded-For header.
        // X-Forwarded-For: <client> [, <proxy>]...
        $forwardedFor = explode(',', str_replace(' ', '', $forwardedFor));

        // The client address is the first item, remove it from the list.
        $clientAddress = array_shift($forwardedFor);

        // The remaining items are the proxies through which the X-Forwarded-For
        // header has passed.  The final proxy is the connection's remote address.
        $proxies = $forwardedFor;
        $proxies[] = $remoteAddr;

        // Ensure that every proxy is trusted.
        foreach ($proxies as $proxy) {
            if (!Ip::proxyIsTrusted($proxy)) {
                return [];
            }
        }

        // Add the client address before the list of proxies.
        return array_merge([$clientAddress], $proxies);
    }

    /**
     * Return the IP of the client.
     *
     * The IP is sourced from, in order of preference:
     *
     *   - The X-Real-IP header if $conf[realip] is true.
     *   - The X-Forwarded-For header if all the proxies are trusted by $conf[trustedproxy].
     *   - The TCP/IP connection remote address.
     *   - 0.0.0.0 if all else fails.
     *
     * The 'realip' config value should only be set to true if the X-Real-IP header
     * is being added by the web server, otherwise it may be spoofed by the client.
     *
     * The 'trustedproxy' setting must not allow any IP, otherwise the X-Forwarded-For
     * may be spoofed by the client.
     *
     * @return string Returns an IPv4 or IPv6 address.
     */
    public static function clientIp(): string
    {
        return Ip::clientIps()[0];
    }

    /**
     * Return the IP of the client and the proxies through which the connection has passed.
     *
     * The IPs are sourced from, in order of preference:
     *
     *   - The X-Real-IP header if $conf[realip] is true.
     *   - The X-Forwarded-For header if all the proxies are trusted by $conf[trustedproxies].
     *   - The TCP/IP connection remote address.
     *   - 0.0.0.0 if all else fails.
     *
     * @return string[] Returns an array of IPv4 or IPv6 addresses.
     */
    public static function clientIps(): array
    {
        /* @var Input $INPUT */
        global $INPUT, $conf;

        // IPs in order of most to least preferred.
        $ips = [];

        // Use the X-Real-IP header if it is enabled by the configuration.
        if (!empty($conf['realip']) && $INPUT->server->str('HTTP_X_REAL_IP')) {
            $ips[] = $INPUT->server->str('HTTP_X_REAL_IP');
        }

        // Add the X-Forwarded-For addresses if all proxies are trusted.
        $ips = array_merge($ips, Ip::forwardedFor());

        // Add the TCP/IP connection endpoint.
        $ips[] = $INPUT->server->str('REMOTE_ADDR');

        // Remove invalid IPs.
        $ips = array_filter($ips, static fn($ip) => filter_var($ip, FILTER_VALIDATE_IP));

        // Remove duplicated IPs.
        $ips = array_values(array_unique($ips));

        // Add a fallback if for some reason there were no valid IPs.
        if (!$ips) {
            $ips[] = '0.0.0.0';
        }

        return $ips;
    }
}