<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * HTTP-Selbsttest und Site-Scan. Spricht ausschliesslich die eigene
 * Site an (cURL bevorzugt, Stream-Fallback), folgt Umleitungen begrenzt
 * und liest Antworten nur bis zu einer Obergrenze.
 */

defined('_JEXEC') or die;

class HtpProbe
{
    const MAX_BODY      = 524288; // 512 KB pro Antwort
    const MAX_REDIRECTS = 5;
    const TIMEOUT       = 10;
    const USER_AGENT    = 'HTProtect-Shield/2.0 (Selbsttest; +https://website-bereinigung.de)';

    /**
     * Steht ein HTTP-Client zur Verfuegung?
     *
     * @return boolean
     */
    public static function available()
    {
        return function_exists('curl_init') || ini_get('allow_url_fopen');
    }

    /**
     * URL abrufen (GET).
     *
     * @param   string      $url         Ziel-URL
     * @param   boolean     $withBody    Antwort-Body mitlesen?
     * @param   array|null  $auth        Optional: array(Benutzer, Passwort) fuer Basic-Auth
     * @param   int|null    $timeout     Optionales Gesamt-Timeout in Sekunden (Default self::TIMEOUT)
     * @param   array       $reqHeaders  Optional: zusaetzliche Request-Header-Zeilen
     *                                   (z. B. "If-None-Match: \"x\"" fuer konditionale Abrufe)
     *
     * @return array ['status' => int (0 = Verbindungsfehler; 304 = unveraendert), 'body' => string,
     *                'final_url' => string, 'error' => string, 'etag' => string, 'modified' => string]
     */
    public static function fetch($url, $withBody = true, $auth = null, $timeout = null, $reqHeaders = array())
    {
        $timeout = ($timeout !== null && (int) $timeout > 0) ? (int) $timeout : self::TIMEOUT;

        if (!is_array($reqHeaders)) {
            $reqHeaders = array();
        }

        if (function_exists('curl_init')) {
            return self::fetchCurl($url, $withBody, $auth, $timeout, $reqHeaders);
        }

        if (ini_get('allow_url_fopen')) {
            return self::fetchStream($url, $withBody, $auth, $timeout, $reqHeaders);
        }

        return array('status' => 0, 'body' => '', 'final_url' => $url, 'error' => htp_t('COM_HTPROTECT_PROBE_NOHTTP'), 'etag' => '', 'modified' => '');
    }

    private static function fetchCurl($url, $withBody, $auth = null, $timeout = self::TIMEOUT, $reqHeaders = array())
    {
        $ch = curl_init();

        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
        curl_setopt($ch, CURLOPT_MAXREDIRS, self::MAX_REDIRECTS);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(6, $timeout));
        curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
        curl_setopt($ch, CURLOPT_USERAGENT, self::USER_AGENT);
        curl_setopt($ch, CURLOPT_NOBODY, !$withBody);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);

        if (is_array($reqHeaders) && !empty($reqHeaders)) {
            curl_setopt($ch, CURLOPT_HTTPHEADER, array_values($reqHeaders));
        }

        // Antwort-Header (ETag/Last-Modified) fuer konditionale Folge-Abrufe sammeln.
        $respHeaders = array();
        curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($c, $line) use (&$respHeaders) {
            $respHeaders[] = $line;

            return strlen($line);
        });

        if (is_array($auth) && isset($auth[0], $auth[1])) {
            curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
            curl_setopt($ch, CURLOPT_USERPWD, $auth[0] . ':' . $auth[1]);
        }

        if (defined('CURLOPT_PROTOCOLS') && defined('CURLPROTO_HTTP') && defined('CURLPROTO_HTTPS')) {
            curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
            curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
        }

        $body     = curl_exec($ch);
        $status   = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $finalUrl = (string) curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
        $error    = $body === false ? (string) curl_error($ch) : '';

        curl_close($ch);

        if (!is_string($body)) {
            $body = '';
        }

        if (strlen($body) > self::MAX_BODY) {
            $body = substr($body, 0, self::MAX_BODY);
        }

        return array(
            'status'    => $status,
            'body'      => $body,
            'final_url' => $finalUrl !== '' ? $finalUrl : $url,
            'error'     => $error,
            'etag'      => self::headerValue($respHeaders, 'etag'),
            'modified'  => self::headerValue($respHeaders, 'last-modified'),
        );
    }

    private static function fetchStream($url, $withBody, $auth = null, $timeout = self::TIMEOUT, $reqHeaders = array())
    {
        $options = array(
            'method'          => 'GET',
            'timeout'         => $timeout,
            'follow_location' => 1,
            'max_redirects'   => self::MAX_REDIRECTS,
            'ignore_errors'   => true,
            'user_agent'      => self::USER_AGENT,
        );

        $hdr = '';

        if (is_array($auth) && isset($auth[0], $auth[1])) {
            $hdr .= 'Authorization: Basic ' . base64_encode($auth[0] . ':' . $auth[1]) . "\r\n";
        }

        if (is_array($reqHeaders)) {
            foreach ($reqHeaders as $line) {
                if (is_string($line) && $line !== '') {
                    $hdr .= rtrim($line, "\r\n") . "\r\n";
                }
            }
        }

        if ($hdr !== '') {
            $options['header'] = $hdr;
        }

        $context = stream_context_create(array('http' => $options));

        $status = 0;
        $body   = '';
        $error  = '';
        $handle = @fopen($url, 'rb', false, $context);

        if ($handle === false) {
            // Status trotzdem aus den Headern ziehen, falls vorhanden
            $headers = isset($http_response_header) ? $http_response_header : array();
            $status  = self::statusFromHeaders($headers);

            if ($status === 0) {
                $error = htp_t('COM_HTPROTECT_PROBE_CONNECT_FAILED');
            }

            return array('status' => $status, 'body' => '', 'final_url' => $url, 'error' => $error,
                'etag' => self::headerValue($headers, 'etag'), 'modified' => self::headerValue($headers, 'last-modified'));
        }

        if ($withBody) {
            while (!feof($handle) && strlen($body) < self::MAX_BODY) {
                $chunk = fread($handle, 16384);

                if ($chunk === false) {
                    break;
                }

                $body .= $chunk;
            }
        }

        $meta = stream_get_meta_data($handle);
        fclose($handle);

        $headers = array();

        if (isset($meta['wrapper_data']) && is_array($meta['wrapper_data'])) {
            $headers = $meta['wrapper_data'];
        }

        $status = self::statusFromHeaders($headers);

        // Grenze des Stream-Fallbacks: die FINALE URL nach gefolgten Redirects ist
        // hier nicht zuverlaessig verfuegbar -> 'final_url' bleibt die Eingangs-URL.
        // Folge: die SEO-Fremd-Weiterleitungs-Erkennung ist OHNE cURL blind
        // (fail-safe: kein Fehlalarm). Der cURL-Pfad liefert die finale URL korrekt.
        return array('status' => $status, 'body' => $body, 'final_url' => $url, 'error' => '',
            'etag' => self::headerValue($headers, 'etag'), 'modified' => self::headerValue($headers, 'last-modified'));
    }

    private static function statusFromHeaders($headers)
    {
        $status = 0;

        foreach ($headers as $line) {
            if (is_string($line) && preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $m)) {
                // Bei Redirect-Ketten zaehlt der letzte Statuscode
                $status = (int) $m[1];
            }
        }

        return $status;
    }

    /**
     * Wert eines Antwort-Headers (case-insensitiv) aus einer Header-Zeilen-Liste.
     * Bei Redirect-Ketten zaehlt das LETZTE Vorkommen (finale Antwort). Fuer
     * konditionale Folge-Abrufe (ETag/Last-Modified). '' = nicht vorhanden.
     *
     * @param   array   $headers  Rohe Header-Zeilen ("Name: Wert")
     * @param   string  $name     Header-Name (klein, ohne Doppelpunkt)
     *
     * @return string
     */
    private static function headerValue($headers, $name)
    {
        $val    = '';
        $prefix = strtolower($name) . ':';

        foreach ((array) $headers as $line) {
            if (!is_string($line)) {
                continue;
            }

            if (strncasecmp($line, $prefix, strlen($prefix)) === 0) {
                $val = trim(substr($line, strlen($prefix)));
            }
        }

        return $val;
    }

    // ─────────────────────────────────────────────────────────────────
    //  Selbsttest
    // ─────────────────────────────────────────────────────────────────

    /**
     * Erreichbarkeits-Momentaufnahme vor dem Schreiben.
     *
     * Erfasst zusaetzlich das Backend (/administrator), damit der Selbsttest danach unterscheiden
     * kann, ob /administrator DURCH unser Schreiben unerreichbar wurde (-> Rollback) oder schon
     * VORHER gesperrt war (z. B. eine eigene .htaccess-Regel -> kein sinnloser Rollback, nur Hinweis).
     *
     * @return array ['home' => int, 'admin' => int]
     */
    public static function baseline()
    {
        $result = self::fetch(htp_site_url(), false);
        $admin  = self::fetch(htp_site_url() . 'administrator/index.php', false);

        return array('home' => $result['status'], 'admin' => (int) $admin['status']);
    }

    /**
     * Kompletter Selbsttest nach dem Schreiben.
     *
     * @param   array  $cfg       Konfiguration
     * @param   array  $baseline  Momentaufnahme von baseline()
     * @param   array  $opts      Optionen: ['expect_home_401' => bool] fuer den Notfall-Modus
     *
     * @return array ['checks' => array, 'regression' => bool, 'time' => string]
     */
    public static function selfTest($cfg, $baseline, $opts = array())
    {
        $site         = htp_site_url();
        $checks       = array();
        $expectHome401 = !empty($opts['expect_home_401']);

        // 1) Startseite. Im Notfall-Modus ist die SPERRE das erwartete Ergebnis:
        //    das Cookie-Gate liefert 503 (temporaer, SEO-schonend), ein alter Basic-Auth-
        //    Notfall 401 - beide gelten als "gesperrt = ok".
        // WICHTIG: nach dem Folgen der Weiterleitungen MUSS eine echte 2xx-Seite
        // stehen. Bleibt es bei 3xx, hat der Client das Redirect-Limit erreicht =
        // Weiterleitungs-Schleife (z. B. eine uebernommene Alt-/Admin-Tools-Regel,
        // die mit der Joomla-/HTProtect-Weiterleitung kollidiert) -> Fehler.
        $home     = self::fetch($site, false);
        $homeLoop = self::isRedirectLoop($home['status']);
        // Im Notfall-Modus MUSS die Sperre nachweisbar greifen: die Probe (ohne Zugangs-
        // Cookie) bekommt 503 (Cookie-Gate) bzw. 401 (alter Basic-Auth). Eine noch OFFENE
        // 2xx-Startseite gilt hier NICHT als gesperrt (sonst falsch-gruene Versiegelung).
        $homeOk   = $expectHome401
            ? ($home['status'] === 401 || $home['status'] === 503)
            : ($home['status'] >= 200 && $home['status'] < 300);
        // Kalibrierung: WELCHEN Status liefert DIESER Server fuer "verboten"?
        // Manche Hoster mappen 403 absichtlich auf 404 (Security-by-Obscurity) -
        // dann scheiterten die strikten "=== 403"-Checks (PHP-Schild, config.php)
        // mit 404, obwohl der Schutz greift. Darum eine Anfrage auf eine EXISTIERENDE
        // URL (Startseite) mit einem Query-String, den der Anfrage-Filter (f_query)
        // existenz- UND login-unabhaengig mit [F] blockt. Der Antwortstatus zeigt,
        // wie dieser Server "verboten" ausdrueckt. Nur verwenden, wenn f_query aktiv
        // ist (sonst kein garantierter Block) und die Probe NICHT durchgelassen wird
        // (durchgelassen = .htaccess greift nicht -> strikt bleiben, kein Fehlgruen).
        $forbidden      = array(403 => true);
        $htaccessProven = false; // wurde die .htaccess auf einer EXISTIERENDEN URL nachweislich honoriert?

        if (!empty($cfg['f_query'])) {
            // Test-Payload fragmentiert (byte-identisch), damit ein Hoster-Virenscanner
            // diese Datei nicht wegen des blossen Signatur-Strings fehlmeldet.
            $calUrl = $site . '?htpcal=' . 'base64' . '_decode(';
            $cal    = self::fetch($calUrl, false);
            $cs     = (int) $cal['status'];

            // 4xx (ausser 401/429) = ein Verbots-Status dieses Servers -> der WAF-Filter
            // greift auf einer EXISTIERENDEN URL, die .htaccess wird also honoriert. 200/3xx
            // = durchgelassen -> Filter greift nicht -> NICHT als "verboten" werten.
            if ($cs >= 400 && $cs < 500 && $cs !== 401 && $cs !== 429) {
                $forbidden[$cs] = true;
                $htaccessProven = true;
            }
        }

        $checks[] = array(
            'label'  => $expectHome401 ? htp_t('COM_HTPROTECT_PROBE_HOME401') : htp_t('COM_HTPROTECT_PROBE_HOME'),
            'url'    => $site,
            'status' => $home['status'],
            'ok'     => $homeOk,
            'note'   => $homeLoop ? htp_t('COM_HTPROTECT_PROBE_LOOP') : ($home['status'] === 0 ? $home['error'] : ''),
        );

        // 2) Backend (401 = Basic-Auth aktiv, ebenfalls in Ordnung). Ist der
        //    Backend-Login per EasyCalcCheck-Plus-Token geschuetzt, liefert der
        //    Aufruf OHNE Token kein 401 (ECC+ leitet auf urlFalseToken um) - der
        //    HTTP-Probe kann den Schutz also gar nicht sehen. Darum den Token-
        //    Schutz LOKAL aus der Plugin-Konfig lesen und die Zeile dann als
        //    geschuetzt (ok) werten, unabhaengig vom HTTP-Status, plus Hinweis.
        $admin    = self::fetch($site . 'administrator/index.php', false);
        $eccToken = class_exists('HtpGuard') && HtpGuard::eccBackendProtected();
        // Im Notfall-Modus sperrt das Cookie-Gate AUCH /administrator -> die Probe (ohne
        // Cookie) bekommt 503. Das ist die erwartete Sperre, KEINE Aussperr-Regression.
        $adminOk  = ($admin['status'] >= 200 && $admin['status'] < 400) || $admin['status'] === 401 || $eccToken
            || ($expectHome401 && $admin['status'] === 503);
        $checks[] = array(
            'label'  => htp_t('COM_HTPROTECT_PROBE_BACKEND'),
            'url'    => $site . 'administrator/index.php',
            'status' => $admin['status'],
            'ok'     => $adminOk,
            'note'   => $admin['status'] === 0
                ? $admin['error']
                : ($eccToken
                    ? htp_t('COM_HTPROTECT_PROBE_BACKEND_ECC')
                    : (!$adminOk ? htp_t('COM_HTPROTECT_PROBE_BACKEND_BLOCKED') : '')),
        );

        // 3) Kanarienvogel: muss am Schild abprallen (403). WICHTIG die PATH_INFO-
        //    Form (".php/…") testen, nicht eine nackte ".php": Das Dateischild
        //    blockt eine NICHT vorhandene ".php" bewusst NICHT (sie wird an Joomlas
        //    Router durchgereicht -> dessen 404). Nur die PATH_INFO-/Doppelendungs-
        //    Regel greift existenzunabhaengig mit 403 - genau das beweist, dass die
        //    .htaccess honoriert wird. So gibt es keinen Fehlalarm "Rewrite greift
        //    nicht", obwohl der Schutz korrekt arbeitet (echte, vorhandene Shells
        //    blockt die zweite Regel ueber den -f-Test ohnehin).
        if (!empty($cfg['s_php'])) {
            $canary   = $site . 'htpx-canary-' . substr(sha1((string) mt_rand()), 0, 8) . '.php/htp';
            $probe    = self::fetch($canary, false);
            $ps       = (int) $probe['status'];
            $blocked  = isset($forbidden[$ps]);

            // nginx-vor-Apache (IONOS/Plesk u. v. a.): eine NICHT vorhandene .php
            // beantwortet der nginx SELBST mit 404, BEVOR die Anfrage Apache/.htaccess
            // erreicht - der Kanarienvogel kann die PATH_INFO-Regel dort also gar nicht
            // ausloesen. Ist die .htaccess aber nachweislich honoriert (die WAF-
            // Kalibrierung auf einer EXISTIERENDEN URL wurde geblockt), dann greift das
            // PHP-Schild fuer ECHTE (vorhandene) Shells sehr wohl - das ist KEIN Fehler,
            // sondern gruen mit Hinweis. Ohne diesen Nachweis bleibt das 404 verdaechtig.
            $nginxMiss = (!$blocked && $ps === 404 && $htaccessProven);
            $checks[]  = array(
                'label'  => htp_t('COM_HTPROTECT_PROBE_PHP'),
                'url'    => $canary,
                'status' => $probe['status'],
                // Im Notfall-Modus sperrt das Cookie-Gate auch diesen Testpfad mit 503 -
                // das ist die erwartete Sperre (subsumiert das PHP-Schild), kein Fehler.
                'ok'     => $blocked || $nginxMiss || ($expectHome401 && $ps === 503),
                'note'   => $nginxMiss
                    ? htp_t('COM_HTPROTECT_PROBE_NGINX404')
                    : ((!$blocked && $ps === 404) ? htp_t('COM_HTPROTECT_PROBE_NOTE404') : ''),
            );
        }

        // 4) configuration.php darf nicht offen ausgeliefert werden. Hier ist der
        //    Inhalt die letzte Wahrheit: ein kalibrierter Verbots-Status ODER eine
        //    Antwort, die nachweislich NICHT den Config-Inhalt traegt, gilt als
        //    geschuetzt (deckt auch Server ab, die 403->404 mappen).
        if (!empty($cfg['s_sensitive'])) {
            $probe    = self::fetch($site . 'configuration.php', true);
            $leaked   = self::looksLikeJConfig((string) (isset($probe['body']) ? $probe['body'] : ''));
            // REIN INHALTSBASIERT: Das einzige echte Risiko bei configuration.php ist,
            // dass ihr KLARTEXT-Quelltext (DB-Zugangsdaten) ausgeliefert wird. Der
            // Statuscode ist hier nebensaechlich - ein laufender Server FUEHRT die PHP
            // aus (leere Ausgabe = sicher) oder sperrt sie (403/404, je nach Server).
            // Quelltext sieht man praktisch nur, wenn PHP defekt/deaktiviert ist. Also
            // gilt: geschuetzt, solange KEIN JConfig-Klartext im Body steht - egal ob
            // 200 (ausgefuehrt), 403 oder 404. Das vermeidet den 403/404-Fehlalarm
            // zuverlaessig, ohne je einen echten Leak zu uebersehen.
            $checks[] = array(
                'label'  => htp_t('COM_HTPROTECT_PROBE_CONFIG'),
                'url'    => $site . 'configuration.php',
                'status' => $probe['status'],
                'ok'     => !$leaked,
                'note'   => $leaked ? htp_t('COM_HTPROTECT_PROBE_CONFIG_LEAK') : '',
            );
        }

        // Regression: Startseite war erreichbar und ist es jetzt nicht mehr.
        // Eine Redirect-Schleife NACH dem Schreiben ist immer ein Rueckroll-Grund
        // (bleibt sie nach dem Schreiben aus, weil HTProtect sie behebt, ist
        // $homeLoop = false -> kein Rollback).
        $wasOk      = isset($baseline['home']) && $baseline['home'] >= 200 && $baseline['home'] < 400;

        // Backend-Aussperrung: NUR werten, wenn /administrator VOR dem Schreiben erreichbar war
        // (adminWasOk) und es JETZT blockiert ist. So loest eine bereits VORHER bestehende Sperre
        // (z. B. eine eigene .htaccess-Regel) KEINEN sinnlosen Rollback aus - dafuer gibt es die
        // separate Warnung/den Hinweis. 401 = Basic-Auth = weiterhin erreichbar (ok). Fehlt der
        // Baseline-Wert (aeltere baseline ohne 'admin'), bleibt die alte Logik unveraendert.
        $adminWasOk      = isset($baseline['admin'])
            && (($baseline['admin'] >= 200 && $baseline['admin'] < 400) || $baseline['admin'] === 401);
        $adminRegression = $adminWasOk && !$adminOk;

        $regression = ($wasOk && !$homeOk) || $homeLoop || $adminRegression;

        return array(
            'checks'       => $checks,
            'regression'   => $regression,
            // Prioritaet: Weiterleitungsschleife > Backend-Aussperrung (beide sind harte Rollback-Gruende).
            'reason'       => $homeLoop ? 'redirect_loop' : ($adminRegression ? 'admin_blocked' : ''),
            'admin_status' => (int) $admin['status'],
            'time'         => htp_local_time(time(), 'Y-m-d H:i:s'),
        );
    }

    /**
     * Traegt die Antwort den ROHEN Inhalt einer Joomla-configuration.php (statt
     * ihn zu sperren oder als PHP auszufuehren)? Nur bei einem ECHTEN Leak true:
     * verlangt die JConfig-Klassensignatur ZUSAMMEN mit einer typischen
     * Secret-Eigenschaft. Eine Fehler-/404-Seite oder gerendertes HTML matcht
     * darum nicht (kein Fehlalarm "Leak"). Wird PHP regulaer ausgefuehrt, steht
     * der Quelltext ohnehin nicht im Body.
     *
     * @param   string  $body  Antwort-Body
     *
     * @return boolean
     */
    private static function looksLikeJConfig($body)
    {
        if ($body === '' || stripos($body, 'class JConfig') === false) {
            return false;
        }

        return (bool) preg_match('~public\s+\$(password|secret|host|user|db)\b~i', $body);
    }

    /**
     * Ist der Statuscode nach dem Folgen der Weiterleitungen noch eine
     * Weiterleitung (3xx)? Dann wurde das Redirect-Limit erreicht =
     * Weiterleitungs-Schleife.
     *
     * @param   int  $status  HTTP-Status nach FOLLOWLOCATION
     *
     * @return boolean
     */
    public static function isRedirectLoop($status)
    {
        $status = (int) $status;

        return $status >= 300 && $status < 400;
    }

    // ─────────────────────────────────────────────────────────────────
    //  .htaccess-Funktionstest (Apache hinter nginx zuverlaessig erkennen)
    // ─────────────────────────────────────────────────────────────────

    const CAP_TTL       = 604800; // 7 Tage: bestaetigtes Ergebnis so lange gueltig
    const CAP_RETRY     = 3600;   // 1 Stunde: unklares Ergebnis fruehestens erneut testen
    // Bewusst OHNE fuehrenden Punkt: Sicherheits-Regelwerke (Admin Tools, das
    // eigene Schild) sperren oft Punkt-Ordner -> Schritt A wuerde sonst schon
    // ohne unsere Test-.htaccess 403 liefern und den Test unbrauchbar machen.
    const PROBE_DIR     = 'htprotect-probe';

    /**
     * Wird die von HTProtect erzeugte .htaccess vom Server ueberhaupt
     * ausgewertet? Entscheidend, weil `$_SERVER['SERVER_SOFTWARE']` in
     * Proxy-Setups (Plesk: nginx vorne, Apache + .htaccess dahinter; der
     * FPM-Pool meldet trotzdem "nginx") unzuverlaessig ist. Liefert:
     *   'yes'     - .htaccess greift (Apache/LiteSpeed oder nginx-vor-Apache)
     *   'no'      - .htaccess wird ignoriert (echtes nginx-only)
     *   'unknown' - nicht entscheidbar (kein HTTP-Client, Schreib-/Netzfehler)
     *
     * Meldet die Server-Software KEIN nginx, ist die Antwort ohne Test 'yes'
     * (klassisches Apache - .htaccess gilt). Nur bei gemeldetem nginx wird der
     * aktive Test gefahren. Das Ergebnis wird in der Konfiguration zwischen-
     * gespeichert (CAP_TTL) - der HTTP-Test laeuft also nicht bei jedem Aufruf.
     *
     * @param   array    $cfg         Konfiguration (Cache-Traeger)
     * @param   boolean  $allowProbe  Darf bei fehlendem/altem Cache aktiv
     *                                getestet (HTTP + Schreiben) werden? In
     *                                Status-Schnappschuessen (Kachel/Plugin)
     *                                bewusst false = nur Cache lesen.
     * @param   boolean  $didProbe    (out) true, wenn aktiv getestet wurde
     *                                (Aufrufer muss $cfg dann speichern).
     *
     * @return string 'yes'|'no'|'unknown'
     */
    public static function htaccessCapability(&$cfg, $allowProbe = false, &$didProbe = false)
    {
        $didProbe = false;

        // Klassisches Apache/LiteSpeed: .htaccess gilt, kein Test noetig.
        if (!htp_is_nginx()) {
            return 'yes';
        }

        $sw    = htp_server_software();
        $cache = (isset($cfg['htaccess_cap']) && is_array($cfg['htaccess_cap'])) ? $cfg['htaccess_cap'] : array();
        $res   = isset($cache['r']) ? (string) $cache['r'] : '';
        $ts    = isset($cache['ts']) ? (int) $cache['ts'] : 0;
        $csw   = isset($cache['sw']) ? (string) $cache['sw'] : '';

        // Frischer, zur aktuellen Server-Software passender Cache?
        $fresh = ($res !== '' && $csw === $sw && $ts > 0
            && (time() - $ts) < (($res === 'unknown') ? self::CAP_RETRY : self::CAP_TTL));

        if ($fresh) {
            return $res;
        }

        if (!$allowProbe) {
            // Nur-Lesen (Kachel/Plugin, kein HTTP-Test): veralteten, aber zum
            // SELBEN Server passenden Befund weiternutzen (Servertyp wechselt
            // praktisch nie). Bei Server-Wechsel oder leerem Cache konservativ
            // 'unknown' -> nginx-Weg, bis der naechste Komponentenaufruf testet.
            return ($res !== '' && $csw === $sw) ? $res : 'unknown';
        }

        // Aktiv testen.
        $res = self::probeHtaccess();
        $cfg['htaccess_cap'] = array('r' => $res, 'ts' => time(), 'sw' => $sw);
        $didProbe = true;

        return $res;
    }

    /**
     * Aktiver, isolierter .htaccess-Funktionstest. Legt einen einmaligen
     * Unterordner im Docroot an, prueft ihn ZWEISTUFIG und raeumt restlos auf:
     *   A) nur eine Marker-Datei -> muss oeffentlich als 200 erreichbar sein
     *      (sonst ist der Pfad gar nicht testbar -> 'unknown', kein Fehlurteil).
     *   B) zusaetzlich eine .htaccess, die JEDE Anfrage in dem Ordner sperrt
     *      -> kommt jetzt 403, wertet Apache die .htaccess aus ('yes'); bleibt
     *      es 200, wird sie ignoriert (echtes nginx-only -> 'no').
     * Die Sperr-Regel ist versions-/modulsicher gekapselt (mod_rewrite ODER
     * Require/Deny), damit sie nie einen 500 erzeugt. Der Ordner liegt isoliert
     * und wird sofort geloescht; ein etwaiger Rest sperrt nur sich selbst.
     *
     * @return string 'yes'|'no'|'unknown'
     */
    public static function probeHtaccess()
    {
        if (!self::available()) {
            return 'unknown';
        }

        $docroot = htp_docroot();
        $base    = htp_site_url();

        if ($docroot === '' || $base === '') {
            return 'unknown';
        }

        $token  = substr(sha1(uniqid('htp', true)), 0, 16);
        $dir    = $docroot . '/' . self::PROBE_DIR . '-' . $token;
        $marker = 'probe.txt';
        $url    = rtrim($base, '/') . '/' . self::PROBE_DIR . '-' . $token . '/' . $marker;

        if (!@mkdir($dir, 0755) && !is_dir($dir)) {
            return 'unknown';
        }

        $result = 'unknown';

        // Sperr-Regel: greift NUR, wenn der Server .htaccess auswertet. Beide
        // Apache-Generationen abgedeckt, in <IfModule> gekapselt -> kein 500,
        // wenn ein Modul fehlt (dann bleibt der Ordner offen -> 200 -> 'no').
        $htaccess =
            "# HTProtect .htaccess-Funktionstest - temporaer, kann geloescht werden\n"
            . "<IfModule mod_authz_core.c>\n"
            . "    Require all denied\n"
            . "</IfModule>\n"
            . "<IfModule !mod_authz_core.c>\n"
            . "    <IfModule mod_access_compat.c>\n"
            . "        Order allow,deny\n"
            . "        Deny from all\n"
            . "    </IfModule>\n"
            . "</IfModule>\n";

        if (@file_put_contents($dir . '/' . $marker, "ok\n") !== false) {
            // (A) Ohne Sperre erreichbar?
            $open = self::fetch($url, false, null, 6);

            if ($open['status'] >= 200 && $open['status'] < 300) {
                // (B) Mit Sperre -> 403 beweist .htaccess-Auswertung.
                if (@file_put_contents($dir . '/.htaccess', $htaccess) !== false) {
                    $locked = self::fetch($url, false, null, 6);

                    if ($locked['status'] === 403 || $locked['status'] === 401) {
                        $result = 'yes';
                    } elseif ($locked['status'] >= 200 && $locked['status'] < 300) {
                        $result = 'no';
                    } else {
                        $result = 'unknown';
                    }
                }
            } elseif ($open['status'] === 403 || $open['status'] === 401) {
                // Server sperrt den Pfad schon ohne unsere .htaccess (z. B.
                // nginx-Regel gegen Punkt-Ordner). Nicht verwertbar.
                $result = 'unknown';
            }
        }

        // Restlos aufraeumen (Reihenfolge: Dateien, dann Ordner).
        @unlink($dir . '/.htaccess');
        @unlink($dir . '/' . $marker);
        @rmdir($dir);

        return $result;
    }

    // ─────────────────────────────────────────────────────────────────
    //  Site-Scan
    // ─────────────────────────────────────────────────────────────────

    /**
     * Eigene Site crawlen und Ressourcen gegen die Regeln simulieren.
     *
     * @param   array    $cfg       Konfiguration
     * @param   integer  $maxPages  Maximal abzurufende Seiten
     *
     * @return array
     */
    public static function crawl($cfg, $maxPages = 12)
    {
        if (!self::available()) {
            return array(
                'ok'          => false,
                'error'       => htp_t('COM_HTPROTECT_PROBE_NOHTTP2'),
                'pages'       => array(),
                'checked'     => 0,
                'blocked'     => array(),
                'suggestions' => array(),
            );
        }

        $siteHost  = strtolower((string) parse_url(htp_site_url(), PHP_URL_HOST));
        $sitePath  = $cfg['site_path'];
        $pages     = array();
        $resources = array();

        foreach (self::queueUrls($maxPages) as $pageUrl) {
            $result = self::crawlPage($pageUrl, $siteHost, $sitePath);
            $pages[$pageUrl] = $result['status'];

            foreach ($result['paths'] as $path) {
                if (!isset($resources[$path])) {
                    $resources[$path] = $pageUrl;
                }

                if (count($resources) >= 1000) {
                    break 2;
                }
            }
        }

        $eval = self::evaluate($cfg, $resources);

        return array(
            'ok'          => true,
            'error'       => '',
            'pages'       => $pages,
            'checked'     => $eval['checked'],
            'blocked'     => $eval['blocked'],
            'suggestions' => $eval['suggestions'],
        );
    }

    /**
     * Wie crawl(), aber OHNE HTTP: die verlinkten lokalen Ressourcen direkt aus der
     * Datenbank ziehen (veroeffentlichte Beitraege #__content + eigene Module
     * #__modules) und gegen den Schild simulieren. So wird ALLES auf einmal geprueft -
     * praktisch jede Seite, ohne sie einzeln aufzurufen - bei nahezu null Last (ein paar
     * Queries + Regex, KEIN Netzwerk). Gebatcht + gedeckelt -> auch auf Riesen-Sites
     * schlank. Strikt fail-open.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array ['ok','checked','blocked','suggestions']
     */
    public static function scanDbResources($cfg)
    {
        $db = function_exists('htp_db') ? htp_db() : null;

        if (!$db) {
            return array('ok' => false, 'checked' => 0, 'blocked' => array(), 'suggestions' => array());
        }

        $siteHost  = strtolower((string) parse_url(htp_site_url(), PHP_URL_HOST));
        $sitePath  = isset($cfg['site_path']) ? $cfg['site_path'] : '';
        $base      = htp_site_url();
        $resources = array();
        $cap       = 2000;

        // Beitraege (veroeffentlicht) - gebatcht, damit der Speicher klein bleibt.
        try {
            $offset = 0;
            $batch  = 500;
            $seen   = 0;

            while ($seen < 10000) {
                $q = $db->getQuery(true)
                    ->select($db->quoteName(array('introtext', 'fulltext')))
                    ->from($db->quoteName('#__content'))
                    ->where($db->quoteName('state') . ' = 1');
                $db->setQuery($q, $offset, $batch);
                $rows = (array) $db->loadRowList();

                if (empty($rows)) {
                    break;
                }

                foreach ($rows as $row) {
                    if (!self::collectFromHtml((string) $row[0] . ' ' . (string) $row[1], $resources, $base, $siteHost, $sitePath, $cap)) {
                        break 2;
                    }
                }

                $seen   += count($rows);
                $offset += $batch;

                if (count($rows) < $batch) {
                    break;
                }
            }
        } catch (Exception $e) {
            // fail-open
        } catch (Throwable $e) {
            // fail-open
        }

        // Eigene Module (veroeffentlicht).
        if (count($resources) < $cap) {
            try {
                $q = $db->getQuery(true)
                    ->select($db->quoteName('content'))
                    ->from($db->quoteName('#__modules'))
                    ->where($db->quoteName('published') . ' = 1');
                $db->setQuery($q, 0, 3000);

                foreach ((array) $db->loadColumn() as $c) {
                    if (!self::collectFromHtml($c, $resources, $base, $siteHost, $sitePath, $cap)) {
                        break;
                    }
                }
            } catch (Exception $e) {
                // fail-open
            } catch (Throwable $e) {
                // fail-open
            }
        }

        // Menue-Eintraege (veroeffentlicht): Wrapper-Menues betten die Ziel-URL als
        // JSON-Feld "url" in den params ein (z. B. ein Formular via iframe/AJAX auf
        // images/xy.php) - das faende der reine Inhalts-Scan sonst nicht.
        if (count($resources) < $cap) {
            try {
                $q = $db->getQuery(true)
                    ->select($db->quoteName(array('link', 'params')))
                    ->from($db->quoteName('#__menu'))
                    ->where($db->quoteName('published') . ' = 1');
                $db->setQuery($q, 0, 3000);

                foreach ((array) $db->loadRowList() as $row) {
                    $link    = (string) $row[0];
                    $params  = (string) $row[1];
                    $decoded = json_decode($params, true);

                    if (is_array($decoded) && isset($decoded['url']) && is_string($decoded['url']) && $decoded['url'] !== '') {
                        if (!self::addResource($decoded['url'], $resources, $base, $siteHost, $sitePath, $cap)) {
                            break;
                        }
                    }

                    if (!self::collectFromHtml(str_replace('\\/', '/', $link . ' ' . $params), $resources, $base, $siteHost, $sitePath, $cap)) {
                        break;
                    }
                }
            } catch (Exception $e) {
                // fail-open
            } catch (Throwable $e) {
                // fail-open
            }
        }

        $eval = self::evaluate($cfg, $resources);

        return array(
            'ok'          => true,
            'checked'     => $eval['checked'],
            'blocked'     => $eval['blocked'],
            'suggestions' => $eval['suggestions'],
        );
    }

    /**
     * Lokale Ressourcen-Pfade aus einem HTML-Blob (Beitrag/Modul) einsammeln und in
     * $resources (Pfad => 'db') aufnehmen. Liefert false, sobald der Deckel erreicht ist.
     *
     * @param   string  $html       HTML-Inhalt
     * @param   array   $resources  Sammler (Referenz)
     * @param   string  $base       Basis-URL zum Aufloesen relativer Links
     * @param   string  $siteHost   Eigener Host (lowercase)
     * @param   string  $sitePath   Unterverzeichnis ('' oder '/pfad')
     * @param   integer $cap        Obergrenze eindeutiger Ressourcen
     *
     * @return boolean  true = weiter, false = Deckel erreicht
     */
    private static function collectFromHtml($html, &$resources, $base, $siteHost, $sitePath, $cap)
    {
        $html = (string) $html;

        if ($html === '' || strpos($html, '/') === false) {
            return true;
        }

        // HTML-Attribute (href/src/action/poster/data-*) UND per JavaScript nachgeladene
        // Endpunkte (AJAX-Formulare, Matomo ...) - z. B. var awf_file='/images/gfsWG.php'.
        // Ohne die JS-Endpunkte uebersaehe der Scan genau solche script-eingebundenen
        // Formulare/Downloads im Bilder-Ordner (Arclab-Formular u. ae.).
        $raws = self::extractUrls($html);

        foreach (self::extractScriptEndpoints($html) as $extra) {
            $raws[] = $extra;
        }

        foreach ($raws as $raw) {
            if (!self::addResource($raw, $resources, $base, $siteHost, $sitePath, $cap)) {
                return false;
            }
        }

        return true;
    }

    /**
     * Einen rohen Verweis aufloesen und - wenn lokal - in $resources aufnehmen.
     *
     * @return boolean  true = weiter, false = Deckel erreicht
     */
    private static function addResource($raw, &$resources, $base, $siteHost, $sitePath, $cap)
    {
        $resolved = self::resolveUrl($base, (string) $raw);

        if ($resolved === '') {
            return true;
        }

        $path = self::localPath($resolved, $siteHost, $sitePath);

        if ($path === '' || $path === '/') {
            return true;
        }

        if (!isset($resources[$path])) {
            $resources[$path] = 'db';
        }

        return count($resources) < $cap;
    }

    /**
     * Zu pruefende Seiten ermitteln (Startseite + Menueseiten).
     *
     * @param   integer  $maxPages  Maximalanzahl
     *
     * @return array
     */
    public static function queueUrls($maxPages)
    {
        $queue = array(htp_site_url());

        foreach (self::menuUrls($maxPages) as $url) {
            $queue[] = $url;
        }

        return array_slice(array_values(array_unique($queue)), 0, max(1, (int) $maxPages));
    }

    /**
     * Eine einzelne Seite abrufen und ihre lokalen Ressourcen-Pfade einsammeln.
     *
     * @param   string  $url       Seiten-URL
     * @param   string  $siteHost  Eigener Hostname (lowercase)
     * @param   string  $sitePath  Unterverzeichnis ('' oder '/pfad')
     *
     * @return array ['status' => int, 'paths' => array]
     */
    public static function crawlPage($url, $siteHost, $sitePath)
    {
        $result = self::fetch($url, true);
        $paths  = array();

        if ($result['status'] >= 200 && $result['status'] < 400 && $result['body'] !== '') {
            $baseUrl = $result['final_url'] !== '' ? $result['final_url'] : $url;

            // HTML-Attribute UND per JS nachgeladene Endpunkte (Matomo etc.).
            $raws = self::extractUrls($result['body']);

            foreach (self::extractScriptEndpoints($result['body']) as $extra) {
                $raws[] = $extra;
            }

            foreach ($raws as $raw) {
                $resolved = self::resolveUrl($baseUrl, $raw);

                if ($resolved === '') {
                    continue;
                }

                $path = self::localPath($resolved, $siteHost, $sitePath);

                if ($path === '' || $path === '/') {
                    continue;
                }

                $paths[$path] = true;

                if (count($paths) >= 400) {
                    break;
                }
            }
        }

        return array('status' => (int) $result['status'], 'paths' => array_keys($paths));
    }

    /**
     * Eingesammelte Pfade gegen die Schild-Regeln simulieren und
     * Freigabe-Vorschlaege ableiten.
     *
     * @param   array  $cfg        Konfiguration
     * @param   array  $resources  Pfad => Fundort-URL
     *
     * @return array ['checked' => int, 'blocked' => array, 'suggestions' => array]
     */
    public static function evaluate($cfg, $resources)
    {
        $blocked = array();

        foreach ($resources as $path => $source) {
            $verdict = HtpBuilder::simulate($cfg, $path);

            if (!$verdict['allowed']) {
                $blocked[] = array(
                    'path'    => $path,
                    'rule'    => $verdict['rule'],
                    'suggest' => $verdict['suggest'],
                    'source'  => $source,
                );
            }
        }

        return array(
            'checked'     => count($resources),
            'blocked'     => $blocked,
            'suggestions' => self::aggregate($blocked),
        );
    }

    /**
     * Eine komplette Anfrage (Pfad + Query-String) gegen ALLE Schutzschichten
     * pruefen und das ausloesende 403 aufdecken - die Kernlogik des URL-Tests.
     * Reihenfolge spiegelt den echten Ablauf: Apache (.htaccess) zuerst, dann
     * die Mini-WAF im Plugin.
     *
     *   1) .htaccess Anfrage-Filter (f_query / f_rfi) am rohen Query-String
     *   2) Exploit-Signatur (.htaccess qs/uri UND Mini-WAF mit Mehrlagen-Decode)
     *   3) .htaccess Pfad-/Datei-Regeln (Lockdown, Dateischild, Dateityp ...)
     *
     * Bewertet aus Besuchersicht (nicht eingeloggt) - so wird sichtbar, was ein
     * normaler Aufruf erleben wuerde (Super-User werden von der WAF nie blockiert).
     *
     * @param   array   $cfg     Konfiguration
     * @param   string  $path    Pfad relativ zum Root (mit fuehrendem '/')
     * @param   string  $query   Roher Query-String (ohne '?')
     * @param   string  $method  HTTP-Methode (Standard GET)
     *
     * @return array  allowed/layer/cause/rule/detail/suggest/filter
     */
    public static function inspectRequest($cfg, $path, $query, $method = 'GET')
    {
        $path  = self::removeDotSegments('/' . ltrim((string) $path, '/'));
        $rel   = ltrim($path, '/');
        $query = (string) $query;

        // 1) .htaccess Anfrage-Filter (Query-String) - Abschnitt 4, greift zuerst.
        $f = HtpBuilder::matchRequestFilter($cfg, $query, $method);

        if (is_array($f)) {
            return self::requestVerdict(
                false, 'htaccess', 'filter',
                htp_t($f['filter'] === 'f_rfi' ? 'COM_HTPROTECT_URLTEST_F_RFI' : 'COM_HTPROTECT_URLTEST_F_QUERY'),
                $f['pattern'], null, $f['filter']
            );
        }

        // 2) Exploit-Signatur - .htaccess (Abschnitt 4b, roh) ODER Mini-WAF
        //    (Plugin, mehrlagig dekodiert). wafMatch deckt qs UND uri ab.
        if (!empty($cfg['x_shield']) && class_exists('HtpDefs')) {
            $sig = HtpDefs::wafMatch($cfg, $query, '', $rel);

            if (!empty($sig) && HtpDefs::wafShouldBlock($sig, false, false)) {
                $frontendOnly = !empty($sig['frontend_only']);
                $underAdmin   = (bool) preg_match('#^administrator(/|$)#i', $rel);

                // frontend_only-Signaturen lassen /administrator unberuehrt
                // (spiegelt sectionExploits + simulate()).
                if (!($frontendOnly && $underAdmin)) {
                    $raw   = self::rawSignatureHit($sig, $query, $rel);
                    $label = HtpDefs::localized($sig, 'label');

                    return self::requestVerdict(
                        false, $raw ? 'htaccess' : 'waf', 'signature',
                        ($label !== '' ? $label : (isset($sig['id']) ? $sig['id'] : '')),
                        isset($sig['id']) ? $sig['id'] : '', null, ''
                    );
                }
            }
        }

        // 3) .htaccess Pfad-/Datei-Regeln (Abschnitte 6/7 + /api + WP-Rauschen).
        $v = HtpBuilder::simulate($cfg, $path);

        if (empty($v['allowed'])) {
            $api = (((string) (isset($cfg['api_mode']) ? $cfg['api_mode'] : 'route')) === 'block')
                && preg_match('#^api(/|$)#i', $rel);

            return self::requestVerdict(
                false, 'htaccess', $api ? 'api' : 'path',
                $v['rule'], '', $v['suggest'], ''
            );
        }

        return self::requestVerdict(true, '', '', $v['rule'], '', null, '');
    }

    /**
     * Wuerde die .htaccess-Variante der Signatur (roher Query/Pfad, [NC])
     * ebenfalls greifen? Dann blockt Apache zuerst -> Ebene ".htaccess".
     * Sonst faengt erst die Mini-WAF (Decode/POST) -> Ebene "waf".
     *
     * @param   array   $sig    Greifende Signatur
     * @param   string  $query  Roher Query-String
     * @param   string  $rel    Pfad ohne fuehrenden Slash
     *
     * @return boolean
     */
    private static function rawSignatureHit($sig, $query, $rel)
    {
        if (!empty($sig['qs']) && @preg_match('#' . $sig['qs'] . '#i', (string) $query) === 1) {
            return true;
        }

        if (!empty($sig['uri']) && @preg_match('#' . $sig['uri'] . '#i', (string) $rel) === 1) {
            return true;
        }

        return false;
    }

    /**
     * Einheitliches Ergebnis des URL-Tests.
     *
     * @param   boolean      $allowed  durchgelassen?
     * @param   string       $layer    '' | 'htaccess' | 'waf'
     * @param   string       $cause    '' | 'filter' | 'signature' | 'path' | 'api'
     * @param   string       $rule     Menschlich lesbarer Grund
     * @param   string       $detail   Muster bzw. Signatur-ID
     * @param   array|null   $suggest  Freigabe-Vorschlag (aus simulate)
     * @param   string       $filter   'f_query' | 'f_rfi' | ''
     *
     * @return array
     */
    private static function requestVerdict($allowed, $layer, $cause, $rule, $detail, $suggest, $filter)
    {
        return array(
            'allowed' => (bool) $allowed,
            'layer'   => $layer,
            'cause'   => $cause,
            'rule'    => $rule,
            'detail'  => $detail,
            'suggest' => $suggest,
            'filter'  => $filter,
        );
    }

    /**
     * Startpunkte aus dem Hauptmenue ableiten (funktioniert mit und ohne SEF).
     *
     * @param   integer  $limit  Maximalanzahl
     *
     * @return array
     */
    private static function menuUrls($limit)
    {
        $urls = array();
        $db   = htp_db();

        if (!$db) {
            return $urls;
        }

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName('id'))
                ->from($db->quoteName('#__menu'))
                ->where($db->quoteName('published') . ' = 1')
                ->where($db->quoteName('client_id') . ' = 0')
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                ->order($db->quoteName('lft'));
            $db->setQuery($query, 0, max(1, (int) $limit - 1));
            $ids = $db->loadColumn();

            if (is_array($ids)) {
                foreach ($ids as $id) {
                    $urls[] = htp_site_url() . 'index.php?Itemid=' . (int) $id;
                }
            }
        } catch (Exception $e) {
            // Menue nicht lesbar - nur Startseite crawlen
        }

        return $urls;
    }

    /**
     * Ressourcen-URLs aus HTML extrahieren.
     *
     * @param   string  $html  Seiteninhalt
     *
     * @return array
     */
    public static function extractUrls($html)
    {
        $found = array();

        // src/href/action/poster/data-src
        if (preg_match_all('~(?:href|src|action|poster|data-src|data-href)\s*=\s*("([^"]*)"|\'([^\']*)\')~i', $html, $m)) {
            foreach ($m[2] as $i => $value) {
                $url = $value !== '' ? $value : $m[3][$i];

                if ($url !== '') {
                    $found[$url] = true;
                }
            }
        }

        // srcset="url 1x, url2 2x"
        if (preg_match_all('~srcset\s*=\s*("([^"]*)"|\'([^\']*)\')~i', $html, $m)) {
            foreach ($m[2] as $i => $value) {
                $set = $value !== '' ? $value : $m[3][$i];

                foreach (explode(',', $set) as $candidate) {
                    $candidate = trim($candidate);
                    $parts     = preg_split('/\s+/', $candidate);

                    if (isset($parts[0]) && $parts[0] !== '') {
                        $found[$parts[0]] = true;
                    }
                }
            }
        }

        // url(...) in Inline-Styles
        if (preg_match_all('~url\(\s*[\'"]?([^\'")]+)[\'"]?\s*\)~i', $html, $m)) {
            foreach ($m[1] as $url) {
                if ($url !== '') {
                    $found[$url] = true;
                }
            }
        }

        return array_keys($found);
    }

    /**
     * Ressourcen-Endpunkte aus eingebettetem JavaScript / Konfig-Blobs ziehen,
     * die NICHT als HTML-Attribut auftauchen - typisch Matomo/Piwik, das per JS
     * nachgeladen wird (z. B. `var u="//host/matomo/"; … u+'matomo.php'` oder der
     * Tag-Manager-Container `…/matomo/js/container_x.js`). Genau diese .php-
     * Endpunkte blockt das Default-Deny-Schild sonst unbemerkt - der Site-Scan
     * soll sie von selbst sehen und zur Freigabe vorschlagen.
     *
     * Erkannt werden:
     *  - Matomo/Piwik: matomo.php/piwik.php direkt, als Geschwister von matomo.js,
     *    aus dem Installationsverzeichnis (.../matomo/) und aus der Konkatenations-
     *    Form `<var> + 'matomo.php'` (Basis aus `<var> = "…/"`).
     *  - generische, eindeutig gerootete .php-Endpunkte als String-Literal.
     *
     * @param   string  $html  Seiteninhalt
     *
     * @return array Liste roher URL-/Pfad-Strings (wie extractUrls)
     */
    public static function extractScriptEndpoints($html)
    {
        $found = array();
        $html  = (string) $html;

        // 1) Matomo/Piwik: jede quotierte URL mit "matomo"/"piwik" auswerten und
        //    auf den matomo.php-Tracker-Endpunkt zurueckrechnen.
        if (preg_match_all('~[\'"]((?:https?:)?//[^\'"<>\s]+|/[^\'"<>\s]+)[\'"]~', $html, $um)) {
            foreach ($um[1] as $cand) {
                $low = strtolower($cand);

                if (strpos($low, 'matomo') === false && strpos($low, 'piwik') === false) {
                    continue;
                }

                // a) direkter .php-Endpunkt (matomo.php / piwik.php)
                if (preg_match('~\.php(?:$|\?)~i', $cand)) {
                    $found[$cand] = true;
                }

                // b) matomo.js / piwik.js -> Geschwister matomo.php (gleicher Ordner)
                if (preg_match('~^(.*/)(?:matomo|piwik)\.js(?:$|\?)~i', $cand, $sm)) {
                    $found[$sm[1] . 'matomo.php'] = true;
                }

                // c) Installationsverzeichnis .../matomo/ bzw. .../piwik/
                //    (faengt auch den Tag-Manager-Container .../matomo/js/…)
                if (preg_match('~^(.*?/(?:matomo|piwik)/)~i', $cand, $dm)) {
                    $found[$dm[1] . 'matomo.php'] = true;
                }

                if (count($found) >= 200) {
                    break;
                }
            }
        }

        // 2) Konkatenations-Form `<var> + 'matomo.php'` ohne Slash im Literal:
        //    Basis-URL aus der `<var> = "…/"`-Zuweisung rekonstruieren.
        if (preg_match_all('~([A-Za-z_$][\w$]*)\s*\+\s*[\'"](?:matomo|piwik)\.(?:php|js)[\'"]~i', $html, $cm, PREG_SET_ORDER)) {
            foreach ($cm as $c) {
                if (preg_match('~\b' . preg_quote($c[1], '~') . '\s*=\s*[\'"]([^\'"]+?/)[\'"]~', $html, $bm)) {
                    $found[$bm[1] . 'matomo.php'] = true;
                }
            }
        }

        // 3) Generische, eindeutig gerootete .php-Endpunkte als String-Literal
        //    (root-relativ, protokoll-relativ oder absolut) - z. B. AJAX-Endpunkte.
        if (preg_match_all('~[\'"]((?:https?:)?//[^\'"<>\s]+?\.php|/[^\'"<>\s]+?\.php)(?:\?[^\'"<>\s]*)?[\'"]~i', $html, $gm)) {
            foreach ($gm[1] as $u) {
                $found[$u] = true;

                if (count($found) >= 400) {
                    break;
                }
            }
        }

        return array_keys($found);
    }

    /**
     * Relative URL gegen Basis-URL aufloesen.
     *
     * @param   string  $base  Basis-URL (Seite)
     * @param   string  $rel   Gefundener Verweis
     *
     * @return string Absolute URL oder ''
     */
    public static function resolveUrl($base, $rel)
    {
        $rel = trim(html_entity_decode($rel, ENT_QUOTES, 'UTF-8'));

        if ($rel === '' || $rel[0] === '#') {
            return '';
        }

        $lower = strtolower($rel);

        foreach (array('java' . 'script:', 'mailto:', 'tel:', 'data:', 'blob:', 'about:') as $skip) {
            if (strpos($lower, $skip) === 0) {
                return '';
            }
        }

        if (preg_match('#^https?://#i', $rel)) {
            return $rel;
        }

        $parts = parse_url($base);

        if (!is_array($parts) || !isset($parts['scheme'], $parts['host'])) {
            return '';
        }

        $origin = $parts['scheme'] . '://' . $parts['host'] . (isset($parts['port']) ? ':' . $parts['port'] : '');

        if (strpos($rel, '//') === 0) {
            return $parts['scheme'] . ':' . $rel;
        }

        if ($rel[0] === '/') {
            return $origin . self::removeDotSegments($rel);
        }

        // relativ zur aktuellen Seite
        $basePath = isset($parts['path']) ? $parts['path'] : '/';
        $baseDir  = substr($basePath, -1) === '/' ? $basePath : (string) substr($basePath, 0, strrpos($basePath, '/') + 1);

        if ($baseDir === '') {
            $baseDir = '/';
        }

        return $origin . self::removeDotSegments($baseDir . $rel);
    }

    /**
     * Punkt-Segmente ('.' und '..') aus einem Pfad entfernen - exakt wie ein
     * Browser eine URL aufloest und wie Apache den Request normalisiert, BEVOR die
     * .htaccess-Regeln greifen (RFC 3986, remove_dot_segments). So testet der
     * URL-Test genau den Pfad, der real beim Server ankommt: ein redundantes
     * '/./' (z. B. aus Theme-Frameworks wie Astroid, die Menuelinks als
     * '/./index.php/...' rendern) erzeugt keinen Phantom-Treffer der Dotfile-Regel
     * mehr. ECHTE versteckte Segmente (.git, .env, .well-known ...) bleiben als
     * eigener Namensbestandteil erhalten und werden weiterhin geprueft - es geht
     * also KEINE Erkennung verloren. Ein abschliessender '/' bleibt erhalten.
     *
     * @param   string  $path  Pfad mit fuehrendem '/'
     *
     * @return string  Normalisierter Pfad mit fuehrendem '/'
     */
    public static function removeDotSegments($path)
    {
        $path     = (string) $path;
        $trailing = (substr($path, -1) === '/');
        $segments = array();

        foreach (explode('/', $path) as $segment) {
            if ($segment === '.' || $segment === '') {
                continue;
            }

            if ($segment === '..') {
                array_pop($segments);
                continue;
            }

            $segments[] = $segment;
        }

        $out = '/' . implode('/', $segments);

        if ($trailing && $out !== '/') {
            $out .= '/';
        }

        return $out;
    }

    /**
     * URL in lokalen Pfad (relativ zum Joomla-Root) umwandeln.
     *
     * @param   string  $url       Absolute URL
     * @param   string  $siteHost  Eigener Hostname
     * @param   string  $sitePath  Unterverzeichnis ('' oder '/pfad')
     *
     * @return string Pfad mit fuehrendem '/' oder ''
     */
    public static function localPath($url, $siteHost, $sitePath)
    {
        $parts = parse_url($url);

        if (!is_array($parts) || !isset($parts['host'])) {
            return '';
        }

        $host = strtolower($parts['host']);
        $own  = array($siteHost, 'www.' . $siteHost, (string) preg_replace('/^www\./', '', $siteHost));

        if (!in_array($host, $own, true)) {
            return '';
        }

        $path = isset($parts['path']) ? $parts['path'] : '/';
        $path = rawurldecode($path);

        if ($sitePath !== '') {
            if (stripos($path . '/', $sitePath . '/') !== 0) {
                return '';
            }

            $path = (string) substr($path, strlen($sitePath));
        }

        if ($path === '') {
            $path = '/';
        }

        if (strpos($path, chr(0)) !== false || strpos($path, '..') !== false) {
            return '';
        }

        // '/./' u. ae. herausnormalisieren - sonst meldet der URL-Test ein
        // redundantes '/.' (z. B. Astroid-Menuelinks '/./index.php/...') faelschlich
        // als "Versteckte Datei/Verzeichnis", obwohl Browser/Apache es ohnehin
        // entfernen, bevor die Regel greift.
        return self::removeDotSegments('/' . ltrim($path, '/'));
    }

    /**
     * Blockierte Funde zu konkreten Freigabe-Vorschlaegen verdichten:
     * mehrere PHP-Dateien im selben Ordner ergeben einen Ordner-Vorschlag,
     * unbekannte Endungen einen Endungs-Vorschlag.
     *
     * @param   array  $blocked  Ergebnis der Simulation
     *
     * @return array
     */
    public static function aggregate($blocked)
    {
        $files = array();
        $exts  = array();

        foreach ($blocked as $item) {
            if (!is_array($item['suggest'])) {
                continue;
            }

            $type  = $item['suggest']['type'];
            $value = $item['suggest']['value'];

            if ($type === 'file') {
                $files[$value] = $item['path'];
            } elseif ($type === 'ext') {
                if (!isset($exts[$value])) {
                    $exts[$value] = array();
                }

                $exts[$value][] = $item['path'];
            }
        }

        $suggestions = array();

        // PHP-/Datei-Freigaben: nach Ordnern gruppieren
        $byDir = array();

        foreach ($files as $file => $path) {
            $dir = dirname($file);
            $dir = ($dir === '.' || $dir === '/') ? '' : $dir;

            if (!isset($byDir[$dir])) {
                $byDir[$dir] = array();
            }

            $byDir[$dir][] = $file;
        }

        foreach ($byDir as $dir => $list) {
            sort($list);

            // Verifizierter Design-/Asset-Ordner (enthaelt einen sich-selbst-deklarierenden, malware-
            // sauberen CSS/JS-Generator): IMMER als SICHERE Ordner-Freigabe anbieten - unabhaengig von der
            // Anzahl -, weil ein Design-Ordner oft viele verschiedene und kuenftige Asset-.php-Namen
            // enthaelt; die Ordner-Freigabe deckt sie alle ab und erspart wiederkehrende 403.
            if ($dir !== '' && class_exists('HtpScanner') && method_exists('HtpScanner', 'isVerifiedAssetDir')
                && HtpScanner::isVerifiedAssetDir(htp_docroot() . $dir)) {
                $suggestions[] = array(
                    'key'   => 'dir_php',
                    'value' => $dir,
                    'count' => count($list),
                    'paths' => array_slice($list, 0, 6),
                    'asset' => true,
                );
                continue;
            }

            if ($dir !== '' && count($list) >= 2) {
                $suggestions[] = array(
                    'key'   => 'dir_php',
                    'value' => $dir,
                    'count' => count($list),
                    'paths' => array_slice($list, 0, 6),
                );
                continue;
            }

            foreach ($list as $file) {
                $suggestions[] = array(
                    'key'   => 'file',
                    'value' => $file,
                    'count' => 1,
                    'paths' => array($file),
                );
            }
        }

        foreach ($exts as $ext => $paths) {
            sort($paths);
            $suggestions[] = array(
                'key'   => 'ext',
                'value' => $ext,
                'count' => count($paths),
                'paths' => array_slice($paths, 0, 6),
            );
        }

        return $suggestions;
    }
}
