<?php
if (!defined('ABSPATH')) exit;

/**
 * Centralized validation for administrator-configurable public hosts.
 *
 * Keeps SSRF defenses consistent across Mastodon, Gemini URL context,
 * and any future integration that accepts a remote host.
 */
class Calliope_SP_Host_Validator {

    /**
     * Validate a public Internet host.
     *
     * @param string $host        Hostname or IP address without scheme/path.
     * @param bool   $resolve_dns Whether DNS records must also be checked.
     * @return bool
     */
    public static function is_safe_public_host($host, $resolve_dns = true) {
        $host = strtolower(trim((string) $host));
        $host = trim(rtrim($host, '.'), '[]');

        if ($host === '' || strlen($host) > 253) {
            return false;
        }

        if ($host === 'localhost' || substr($host, -10) === '.localhost' || substr($host, -6) === '.local') {
            return false;
        }

        if (self::is_tunnel_host($host)) {
            return false;
        }

        if (filter_var($host, FILTER_VALIDATE_IP)) {
            return self::is_public_ip($host);
        }

        // Reject legacy numeric IP spellings such as 2130706433, 017700000001,
        // or 0x7f000001 before a URL client can reinterpret them as localhost.
        if (self::is_numeric_host($host)) {
            return false;
        }

        if (strpos($host, '.') === false || preg_match('/[^a-z0-9.-]/', $host)) {
            return false;
        }

        $labels = explode('.', $host);
        foreach ($labels as $label) {
            if ($label === '' || strlen($label) > 63 || $label[0] === '-' || substr($label, -1) === '-') {
                return false;
            }
        }

        $tld = end($labels);
        if (in_array($tld, ['localhost', 'local', 'invalid', 'test', 'example'], true)) {
            return false;
        }

        return !$resolve_dns || !self::host_resolves_to_private_address($host);
    }

    /** @return bool */
    private static function is_tunnel_host($host) {
        $host = strtolower(trim((string) $host));
        return (bool) preg_match('/(^|\\.)ngrok(?:-free)?\\.(?:io|app|dev)$|(^|\\.)pinggy\\.(?:io|link)$/', $host);
    }

    /** @return bool */
    private static function is_numeric_host($host) {
        return (bool) preg_match('/^(?:0x[0-9a-f]+|0[0-7]+|\\d+)(?:\\.(?:0x[0-9a-f]+|0[0-7]+|\\d+))*$/i', trim((string) $host, '[]'));
    }

    /** @return bool */
    private static function is_public_ip($ip) {
        $ip = trim((string) $ip, '[]');

        // IPv4-mapped IPv6 needs explicit handling because generic filters can
        // otherwise classify the IPv6 wrapper differently from its IPv4 target.
        if (stripos($ip, '::ffff:') === 0) {
            $mapped_ipv4 = substr($ip, 7);
            if (filter_var($mapped_ipv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
                return self::is_public_ipv4($mapped_ipv4);
            }
        }

        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
            return self::is_public_ipv4($ip);
        }

        return (bool) filter_var(
            $ip,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
        );
    }

    /** @return bool */
    private static function is_public_ipv4($ip) {
        $ip = trim((string) $ip);
        if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
            return false;
        }

        // Explicitly cover special-use ranges in addition to PHP's filters.
        $blocked_ranges = [
            '0.0.0.0/8',
            '10.0.0.0/8',
            '100.64.0.0/10',
            '127.0.0.0/8',
            '169.254.0.0/16',
            '172.16.0.0/12',
            '192.0.0.0/24',
            '192.0.2.0/24',
            '192.88.99.0/24',
            '192.168.0.0/16',
            '198.18.0.0/15',
            '198.51.100.0/24',
            '203.0.113.0/24',
            '224.0.0.0/4',
            '240.0.0.0/4',
        ];

        foreach ($blocked_ranges as $cidr) {
            if (self::ipv4_in_cidr($ip, $cidr)) {
                return false;
            }
        }

        return (bool) filter_var(
            $ip,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
        );
    }

    /** @return bool */
    private static function ipv4_in_cidr($ip, $cidr) {
        $parts = explode('/', (string) $cidr, 2);
        if (count($parts) !== 2) {
            return false;
        }

        $prefix = (int) $parts[1];
        if ($prefix < 0 || $prefix > 32) {
            return false;
        }

        $ip_long = ip2long((string) $ip);
        $network_long = ip2long((string) $parts[0]);
        if ($ip_long === false || $network_long === false) {
            return false;
        }

        $ip_long = (int) sprintf('%u', $ip_long);
        $network_long = (int) sprintf('%u', $network_long);
        $mask = $prefix === 0 ? 0 : ((0xffffffff << (32 - $prefix)) & 0xffffffff);

        return ($ip_long & $mask) === ($network_long & $mask);
    }

    /** @return bool */
    private static function host_resolves_to_private_address($host) {
        $ips = [];
        $checked_dns_records = false;

        if (function_exists('dns_get_record')) {
            $records = @dns_get_record($host, DNS_A + DNS_AAAA);
            if (is_array($records)) {
                $checked_dns_records = true;
                foreach ($records as $record) {
                    if (!empty($record['ip'])) {
                        $ips[] = (string) $record['ip'];
                    }
                    if (!empty($record['ipv6'])) {
                        $ips[] = (string) $record['ipv6'];
                    }
                }
            }
        }

        if (empty($ips) && !$checked_dns_records && function_exists('gethostbynamel')) {
            $resolved = @gethostbynamel($host);
            if (is_array($resolved)) {
                $ips = array_merge($ips, array_map('strval', $resolved));
            }
        }

        foreach (array_unique($ips) as $ip) {
            if (!self::is_public_ip($ip)) {
                return true;
            }
        }

        return false;
    }
}
