<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * IIS-Schild: erzeugt aus DERSELBEN Konfiguration wie das Apache-Schild eine
 * vollstaendige web.config fuer Microsoft IIS (URL Rewrite + Request Filtering).
 *
 * Konstruktionsprinzipien fuer Fehlerfreiheit:
 * - GENERATE-ONLY (wie das nginx-Schild): IIS liest eine web.config im Webroot
 *   SOFORT und OHNE Neustart - eine ungueltige Regel liefert 500 fuer die GANZE
 *   Site. Darum wird sie NIE automatisch aktiviert; der Betreiber prueft sie und
 *   platziert sie selbst (idealerweise erst auf einem Test-/Staging-System).
 * - Alle Sperren als URL-Rewrite-Regeln mit CustomResponse 403 - kein Rueckgriff
 *   auf potenziell serverseitig gesperrte Request-Filtering-Abschnitte (removeServer
 *   Header/verbs stehen nur als Kommentar bei, weil sie IIS-Versions-/Lock-abhaengig
 *   sind und sonst einen 500 riskieren).
 * - Pfade sind RELATIV zum Site-Root, in dem die web.config liegt (IIS-Match-URL
 *   ohne fuehrenden Slash, ohne Site-Praefix) - anders als bei Apache/nginx.
 * - Alle Muster werden XML-Attribut-sicher escaped (& < > " '). Der Regex-Motor
 *   von URL Rewrite ist .NET (weitgehend PCRE-kompatibel fuer diese Muster).
 * - Der Joomla-Router liegt nur als AUSKOMMENTIERTE Vorlage bei (Joomlas eigene
 *   web.config bringt ihn ueblicherweise schon mit).
 */

defined('_JEXEC') or die;

class HtpIis
{
    /**
     * Komplette web.config erzeugen.
     *
     * @param   array  $cfg  Konfiguration (identisch zum Apache-Schild)
     *
     * @return string  Leer bei unausgeglichenen Tags (Paranoia-Check)
     */
    public static function build($cfg)
    {
        $now   = htp_local_time(time(), 'Y-m-d H:i:s');
        $head  = array();

        $head[] = '<?xml version="1.0" encoding="UTF-8"?>';
        $head[] = '<!--';
        $head[] = '  ' . htp_tc('COM_HTPROTECT_IIS_HEAD_TITLE', 'IIS-Serverschild (web.config) - erzeugt von HTProtect Server-Schild') . ' ' . HTP_VERSION;
        $head[] = '  ' . htp_tc('COM_HTPROTECT_HTC_HEAD_DATE', 'Stand:') . ' ' . $now . ' | website-bereinigung.de';
        $head[] = '  ========================================================================';
        $head[] = '  ' . htp_tc('COM_HTPROTECT_IIS_HEAD_NEEDMOD', 'VORAUSSETZUNG: IIS-Modul "URL Rewrite" muss installiert sein.');
        $head[] = '  ' . htp_tc('COM_HTPROTECT_IIS_HEAD_PLACE1', 'EINBAU: Inhalt pruefen und als Datei web.config in den Site-Root legen.');
        $head[] = '  ' . htp_tc('COM_HTPROTECT_IIS_HEAD_PLACE2', 'Gibt es dort SCHON eine web.config, NICHT ueberschreiben - die einzelnen');
        $head[] = '  ' . htp_tc('COM_HTPROTECT_IIS_HEAD_PLACE3', 'Regeln stattdessen in deren Rewrite-Regeln (rewrite/rules) einfuegen.');
        $head[] = '  ' . htp_tc('COM_HTPROTECT_IIS_HEAD_WARN1', 'ACHTUNG: IIS liest die web.config SOFORT - eine ungueltige Regel liefert');
        $head[] = '  ' . htp_tc('COM_HTPROTECT_IIS_HEAD_WARN2', '500 fuer die ganze Site. Zuerst auf einem Test-/Staging-System pruefen.');
        $head[] = '  ' . htp_tc('COM_HTPROTECT_IIS_HEAD_OWNRULES', 'Die "Eigenen Regeln" der Komponente sind Apache-Syntax und fehlen hier bewusst.');
        $head[] = '  htprotect-iis:begin v=' . HTP_VERSION . ' cfg=' . HtpBuilder::configHash($cfg) . ' written=' . $now;
        $head[] = '-->';
        $head[] = '<configuration>';
        $head[] = '  <system.webServer>';

        // Nicht-Rewrite-Bausteine (Header, Verzeichnislisten, Caching) + die Rewrite-Regeln getrennt
        // sammeln und danach in der korrekten Reihenfolge unter system.webServer setzen.
        $top   = array();  // directoryBrowse / httpProtocol / staticContent / urlCompression
        $rules = array();  // <rule>-Bloecke fuer <rewrite><rules>

        self::secBasics($top, $cfg);
        self::secHeaders($top, $cfg);
        self::secPerformance($top, $cfg);

        self::secFilters($rules, $cfg);
        self::secExploits($rules, $cfg);
        self::secCanonical($rules, $cfg);
        self::secLockdown($rules, $cfg);
        self::secShield($rules, $cfg);

        $body = $top;

        if (!empty($rules)) {
            $body[] = '';
            $body[] = '    <rewrite>';
            $body[] = '      <rules>';

            foreach ($rules as $line) {
                $body[] = $line;
            }

            $body[] = '      </rules>';
            $body[] = '    </rewrite>';
        }

        self::secRouter($body, $cfg);

        $foot   = array();
        $foot[] = '';
        $foot[] = '    <!-- htprotect-iis:end -->';
        $foot[] = '  </system.webServer>';
        $foot[] = '</configuration>';

        $content = implode("\n", array_merge($head, $body, $foot)) . "\n";

        // Paranoia-Check: die tragenden Container-Tags muessen paarig sein, sonst
        // waere die web.config kaputt (und IIS wuerde die Site 500en).
        foreach (array('configuration', 'system.webServer', 'rewrite', 'rules', 'rule') as $tag) {
            if (substr_count($content, '<' . $tag . ' ') + substr_count($content, '<' . $tag . '>')
                !== substr_count($content, '</' . $tag . '>')) {
                return '';
            }
        }

        return $content;
    }

    // ─────────────────────────────────────────────────────────────────
    //  Abschnitte (Nummerierung wie beim Apache-/nginx-Schild)
    // ─────────────────────────────────────────────────────────────────

    private static function secBasics(&$out, $cfg)
    {
        if (empty($cfg['apache_options'])) {
            return;
        }

        self::head($out, '1', htp_tc('COM_HTPROTECT_HTC_HEAD_BASICS', 'Grundlagen'));
        $out[] = '    <directoryBrowse enabled="false" />';
        $out[] = '    <!-- ' . htp_tc('COM_HTPROTECT_IIS_SERVERHEADER', 'Server-Version verstecken (nur IIS 10+; bei aelteren IIS diese Zeile weglassen):') . ' -->';
        $out[] = '    <!-- <security><requestFiltering removeServerHeader="true" /></security> -->';
        $out[] = '';
    }

    private static function secHeaders(&$out, $cfg)
    {
        $add    = array();
        $remove = array();

        if (!empty($cfg['h_frame'])) {
            $add['X-Frame-Options'] = 'SAMEORIGIN';
        }

        if (!empty($cfg['h_nosniff'])) {
            $add['X-Content-Type-Options'] = 'nosniff';
        }

        if (!empty($cfg['h_referrer']) && is_string($cfg['h_referrer'])) {
            $add['Referrer-Policy'] = $cfg['h_referrer'];
        }

        if (!empty($cfg['h_coop'])) {
            $add['Cross-Origin-Opener-Policy'] = 'same-origin';
        }

        if (!empty($cfg['h_permissions'])) {
            $add['Permissions-Policy'] = 'camera=(), geolocation=(), microphone=(), payment=()';
        }

        if (!empty($cfg['h_hsts']) && !empty($cfg['force_https'])) {
            $add['Strict-Transport-Security'] = 'max-age=31536000' . (!empty($cfg['h_hsts_sub']) ? '; includeSubDomains' : '');
        }

        if (!empty($cfg['h_poweredby'])) {
            $remove[] = 'X-Powered-By';
        }

        if (empty($add) && empty($remove)) {
            return;
        }

        self::head($out, '3', htp_tc('COM_HTPROTECT_HTC_HEAD_HEADERS', 'Antwort-Header'));
        $out[] = '    <httpProtocol>';
        $out[] = '      <customHeaders>';

        foreach ($add as $name => $value) {
            $out[] = '        <add name="' . self::xa($name) . '" value="' . self::xa($value) . '" />';
        }

        foreach ($remove as $name) {
            $out[] = '        <remove name="' . self::xa($name) . '" />';
        }

        $out[] = '      </customHeaders>';
        $out[] = '    </httpProtocol>';
        $out[] = '';
    }

    private static function secPerformance(&$out, $cfg)
    {
        $compress = !empty($cfg['p_compress']);
        $expires  = !empty($cfg['p_expires']);

        if (!$compress && !$expires) {
            return;
        }

        self::head($out, '8', htp_tc('COM_HTPROTECT_HTC_HEAD_DELIVERY', 'Auslieferung und Caching'));

        if ($expires) {
            $out[] = '    <!-- ' . htp_tc('COM_HTPROTECT_IIS_D_EXPIRES', 'Browser-Cache fuer statische Inhalte (HTML bleibt bewusst frisch).') . ' -->';
            $out[] = '    <staticContent>';
            $out[] = '      <clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="365.00:00:00" />';
            $out[] = '    </staticContent>';
            $out[] = '';
        }

        if ($compress) {
            $out[] = '    <!-- ' . htp_tc('COM_HTPROTECT_IIS_D_COMPRESS', 'Kompression (dynamische Kompression nur, wenn das Modul installiert ist - sonst die dynamic-Zeile weglassen):') . ' -->';
            $out[] = '    <urlCompression doStaticCompression="true" doDynamicCompression="true" />';
            $out[] = '';
        }

        // ETag laesst sich in IIS nicht zuverlaessig per web.config abschalten (Registry/
        // Outbound-Rule) -> bewusst ausgelassen, statt eine fragile Regel zu erzeugen.
    }

    private static function secFilters(&$out, $cfg)
    {
        $methods = !empty($cfg['f_methods']);
        $query   = !empty($cfg['f_query']);
        $rfi     = !empty($cfg['f_rfi']);
        $noise   = !empty($cfg['f_wpnoise']);

        if (!$methods && !$query && !$rfi && !$noise) {
            return;
        }

        self::rhead($out, '4', htp_tc('COM_HTPROTECT_HTC_HEAD_FILTERS', 'Anfrage-Filter'));

        if ($methods) {
            self::ruleMethod($out, 'methods', '^(TRACE|TRACK|DEBUG)$', htp_tc('COM_HTPROTECT_HTC_F_METHODS', 'Diagnose-Methoden gehoeren nicht in den Produktivbetrieb'));
        }

        if ($query) {
            $qs = array(
                '%00',
                '(\.\./|\.\.%2f|%2e%2e/|%2e%2e%2f)',
                '(data|expect|glob|ogg|phar|php|zlib)(://|%3a%2f%2f)',
                'base64_(en|de)code\(',
                '(<|%3C)script',
                'proc/sel[f]/enviro[n]',
                'mosConfig_[a-zA-Z_]{1,24}(=|%3D)',
                'GLOBALS(=|\[|%[0-9A-Z]{0,2})',
                '_REQUEST(=|\[|%[0-9A-Z]{0,2})',
                '^=PHP[0-9A-Fa-f\-]{10,}',
            );

            $i = 0;

            foreach ($qs as $pat) {
                self::ruleQuery($out, 'q' . $i, $pat, $i === 0 ? htp_tc('COM_HTPROTECT_HTC_F_QUERY', 'Injektions- und Ausspaeh-Muster im Query-String abweisen') : '');
                $i++;
            }
        }

        if ($rfi) {
            // Nur bei GET; ODER-Kette der beiden Muster.
            $out[] = '        <rule name="HTP-rfi" stopProcessing="true">';
            $out[] = '          <match url=".*" />';
            $out[] = '          <conditions logicalGrouping="MatchAll">';
            $out[] = '            <add input="{REQUEST_METHOD}" pattern="^GET$" />';
            $out[] = '            <add input="{QUERY_STRING}" pattern="' . self::xa('=(https?|ftps?)(://|%3a%2f%2f)|=(\.\.//?)+') . '" ignoreCase="true" />';
            $out[] = '          </conditions>';
            $out[] = '          <action type="CustomResponse" statusCode="403" statusReason="Forbidden" statusDescription="Blocked by HTProtect" />';
            $out[] = '        </rule>';
        }

        if ($noise) {
            self::ruleUrl($out, 'wp1', '^(wp-admin|wp-content|wp-includes|wp-json)(/|$)', htp_tc('COM_HTPROTECT_HTC_F_WPNOISE', 'WordPress-Scanrauschen sofort abweisen'));
            self::ruleUrl($out, 'wp2', '^(wp-login|wp-signup|wp-cron|xmlrpc)\.php', '');
        }
    }

    private static function secExploits(&$out, $cfg)
    {
        $apiBlock = ((string) (isset($cfg['api_mode']) ? $cfg['api_mode'] : 'route')) === 'block';
        $sigs     = (!empty($cfg['x_shield']) && class_exists('HtpDefs')) ? HtpDefs::effective($cfg) : array();

        if (!$apiBlock && empty($sigs)) {
            return;
        }

        self::rhead($out, '4b', htp_tc('COM_HTPROTECT_HTC_HEAD_EXPLOIT', 'Exploit-Schild'));

        if ($apiBlock) {
            self::ruleUrl($out, 'api', '^api(/|$)', htp_tc('COM_HTPROTECT_HTC_APIBLOCK_UNUSED', 'Schnittstellen-Anwendung (/api) komplett sperren (ungenutzt)'));
        }

        foreach ($sigs as $sig) {
            $id    = preg_replace('~[^A-Za-z0-9_.-]~', '', (string) $sig['id']);
            $label = self::commentText(HtpDefs::localized($sig, 'label'));
            $front = !empty($sig['frontend_only']);

            if (!empty($sig['qs'])) {
                $out[] = '        <!-- sig:' . self::xc($id) . ' ' . self::xc($label) . ' -->';
                $out[] = '        <rule name="HTP-sig-' . self::xa($id) . '-q" stopProcessing="true">';
                $out[] = '          <match url=".*" />';
                $out[] = '          <conditions logicalGrouping="MatchAll">';
                $out[] = '            <add input="{QUERY_STRING}" pattern="' . self::xa($sig['qs']) . '" ignoreCase="true" />';

                if ($front) {
                    // Nur im Frontend blocken -> im /administrator NICHT.
                    $out[] = '            <add input="{URL}" pattern="' . self::xa('^/administrator/') . '" negate="true" ignoreCase="true" />';
                }

                $out[] = '          </conditions>';
                $out[] = '          <action type="CustomResponse" statusCode="403" statusReason="Forbidden" statusDescription="Blocked by HTProtect" />';
                $out[] = '        </rule>';
            }

            if (!empty($sig['uri'])) {
                $pattern = self::uriToIis($sig['uri']);
                $out[] = '        <!-- sig:' . self::xc($id) . ' ' . self::xc($label) . ' ' . self::xc(htp_tc('COM_HTPROTECT_HTC_SIG_PATH', '(Pfad)')) . ' -->';
                self::ruleUrl($out, 'sig-' . $id . '-u', $pattern, '');
            }
        }
    }

    private static function secCanonical(&$out, $cfg)
    {
        $https = !empty($cfg['force_https']);
        $www   = (int) $cfg['www_mode'];

        if (!$https && $www === 0) {
            return;
        }

        self::rhead($out, '5', htp_tc('COM_HTPROTECT_HTC_HEAD_CANONICAL', 'Kanonische Adresse'));

        $host   = HtpBuilder::canonicalHost($cfg);
        $fixed  = ($www !== 0 || $cfg['canonical_host'] !== '') && $host !== '';

        if ($https) {
            // Unverschluesselt -> auf HTTPS heben. X-Forwarded-Proto verhindert Schleifen hinter Proxys.
            $target = $fixed ? self::xa($host) : '{HTTP_HOST}';
            $out[] = '        <!-- ' . self::xc(htp_tc('COM_HTPROTECT_HTC_C_HTTPS', 'Unverschluesselte Aufrufe dauerhaft auf HTTPS heben')) . ' -->';
            $out[] = '        <rule name="HTP-https" stopProcessing="true">';
            $out[] = '          <match url=".*" />';
            $out[] = '          <conditions logicalGrouping="MatchAll">';
            $out[] = '            <add input="{HTTPS}" pattern="^OFF$" />';
            $out[] = '            <add input="{HTTP_X_FORWARDED_PROTO}" pattern="^https$" negate="true" />';
            $out[] = '          </conditions>';
            $out[] = '          <action type="Redirect" url="https://' . $target . '{REQUEST_URI}" redirectType="Permanent" appendQueryString="false" />';
            $out[] = '        </rule>';

            if ($fixed) {
                $out[] = '        <!-- ' . self::xc(htp_tc('COM_HTPROTECT_HTC_C_HOST', 'Hostnamen vereinheitlichen')) . ' -->';
                $out[] = '        <rule name="HTP-host" stopProcessing="true">';
                $out[] = '          <match url=".*" />';
                $out[] = '          <conditions logicalGrouping="MatchAll">';
                $out[] = '            <add input="{HTTP_HOST}" pattern="^' . self::xa($host) . '$" negate="true" />';
                $out[] = '          </conditions>';
                $out[] = '          <action type="Redirect" url="https://' . self::xa($host) . '{REQUEST_URI}" redirectType="Permanent" appendQueryString="false" />';
                $out[] = '        </rule>';
            }
        } elseif ($www !== 0 && $host !== '') {
            $out[] = '        <!-- ' . self::xc(htp_tc('COM_HTPROTECT_HTC_C_HOST', 'Hostnamen vereinheitlichen, Verschluesselung des Aufrufs beibehalten')) . ' -->';
            $out[] = '        <rule name="HTP-host" stopProcessing="true">';
            $out[] = '          <match url=".*" />';
            $out[] = '          <conditions logicalGrouping="MatchAll">';
            $out[] = '            <add input="{HTTP_HOST}" pattern="^' . self::xa($host) . '$" negate="true" />';
            $out[] = '          </conditions>';
            $out[] = '          <action type="Redirect" url="{MapProtocol}://' . self::xa($host) . '{REQUEST_URI}" redirectType="Permanent" appendQueryString="false" />';
            $out[] = '        </rule>';
            $out[] = '        <!-- ' . self::xc(htp_tc('COM_HTPROTECT_IIS_C_MAPPROTO', 'Hinweis: {MapProtocol} oben ist ein Platzhalter - bei reinem HTTP durch "http" ersetzen, wenn kein Proxy das Schema setzt.')) . ' -->';
        }
    }

    private static function secLockdown(&$out, $cfg)
    {
        $sysdirs   = !empty($cfg['s_sysdirs']) && !empty($cfg['s_sysdirs_list']);
        $dotfiles  = !empty($cfg['s_dotfiles']);
        $sensitive = !empty($cfg['s_sensitive']);
        $manifests = !empty($cfg['s_manifests']);

        if (!$sysdirs && !$dotfiles && !$sensitive && !$manifests) {
            return;
        }

        self::rhead($out, '6', htp_tc('COM_HTPROTECT_HTC_HEAD_LOCKDOWN', 'Sperren fuer interne Bereiche'));

        if ($manifests) {
            self::ruleUrl($out, 'man1', '^(administrator/)?(components|libraries|modules|plugins|templates)/.+\.xml$', htp_tc('COM_HTPROTECT_IIS_L_MANIFEST', 'Erweiterungs-/Kern-Manifeste (.xml) verstecken'));
            self::ruleUrl($out, 'man2', '^administrator/manifests(/|$)', '');
        }

        if ($sysdirs) {
            $dirs = array();

            foreach ($cfg['s_sysdirs_list'] as $dir) {
                $dirs[] = self::rx($dir);
            }

            sort($dirs);
            self::ruleUrl($out, 'sysdirs', '^(' . implode('|', $dirs) . ')(/|$)', htp_tc('COM_HTPROTECT_HTC_L_INTERNAL', 'Interne Verzeichnisse sind ueber das Web tabu'));
        }

        if ($dotfiles) {
            // Versteckte Dateien/Verzeichnisse (.git/.env/.htpasswd ...). {URL} traegt den fuehrenden
            // Slash -> Muster auf {URL} statt der (slash-losen) Match-URL, damit auch der Root-Dotfile
            // greift. Optional .well-known ausnehmen (Zertifikats-Validierung).
            $out[] = '        <!-- ' . self::xc(htp_tc('COM_HTPROTECT_HTC_L_DOTFILES', 'Versteckte Dateien und Verzeichnisse')) . ' -->';
            $out[] = '        <rule name="HTP-dotfiles" stopProcessing="true">';
            $out[] = '          <match url=".*" />';
            $out[] = '          <conditions logicalGrouping="MatchAll">';
            $out[] = '            <add input="{URL}" pattern="' . self::xa('(^|/)\.') . '" />';

            if (!empty($cfg['s_wellknown'])) {
                $out[] = '            <add input="{URL}" pattern="' . self::xa('^/\.well-known(/|$)') . '" negate="true" />';
            }

            $out[] = '          </conditions>';
            $out[] = '          <action type="CustomResponse" statusCode="403" statusReason="Forbidden" statusDescription="Blocked by HTProtect" />';
            $out[] = '        </rule>';
        }

        if ($sensitive) {
            $names = HtpBuilder::SENSITIVE_NAMES;

            // FilesMatch-Anker '^(...)$' auf den Datei-Basename (am URL-Ende) uebertragen.
            if (strpos($names, '^(') === 0 && substr($names, -2) === ')$') {
                $names = substr($names, 2, -2);
            }

            $out[] = '        <!-- ' . self::xc(htp_tc('COM_HTPROTECT_HTC_L_SENSNAME', 'Namentlich bekannte sensible Dateien')) . ' -->';
            self::ruleUrlCI($out, 'sensname', '(^|/)(' . $names . ')$', false);

            $out[] = '        <!-- ' . self::xc(htp_tc('COM_HTPROTECT_HTC_L_SENSEXT', 'Riskante Endungen: Sicherungen, Schluessel, Datenbanken, Logs, Konfigurationen')) . ' -->';
            self::ruleUrlCI($out, 'sensext', HtpBuilder::SENSITIVE_EXT, true);
        }
    }

    private static function secShield(&$out, $cfg)
    {
        $php   = !empty($cfg['s_php']);
        $types = !empty($cfg['s_types']);

        if (!$php && !$types) {
            return;
        }

        self::rhead($out, '7', htp_tc('COM_HTPROTECT_HTC_HEAD_FILESHIELD', 'Dateischild'));

        if ($php) {
            // Freigaben als negative Lookaheads direkt nach dem Pfadanfang (wie beim nginx-Schild).
            $ahead = '';

            foreach (HtpBuilder::coreEntries() as $file) {
                $ahead .= '(?!' . self::rx($file) . '(/|$))';
            }

            foreach ($cfg['s_files'] as $file) {
                if (in_array($file, HtpBuilder::coreEntries(), true)
                    || !preg_match(HtpBuilder::PHP_SIM_PATTERN, '/' . $file)) {
                    continue;
                }

                $ahead .= '(?!' . self::rx($file) . '(/|$))';
            }

            foreach ($cfg['s_dirs_php'] as $dir) {
                $ahead .= '(?!' . self::rx($dir) . '/)';
            }

            $out[] = '        <!-- ' . self::xc(htp_tc('COM_HTPROTECT_HTC_S_PHP', 'PHP laeuft nur ueber freigegebene Einstiegspunkte - alles andere: 403')) . ' -->';
            self::ruleUrl($out, 'phpshield', '^' . $ahead . '.*' . HtpBuilder::PHP_RULE_PATTERN, '');
        }

        if ($types) {
            $extPattern = str_replace(',', '|', $cfg['s_ext']);
            $allowed    = array();

            foreach (HtpBuilder::coreEntries() as $file) {
                $allowed[] = self::rx($file) . '(/|$)';
            }

            foreach ($cfg['s_files'] as $file) {
                if (in_array($file, HtpBuilder::coreEntries(), true)) {
                    continue;
                }

                $allowed[] = self::rx($file) . '(/|$)';
            }

            foreach (array_merge($cfg['s_dirs_static'], $cfg['s_dirs_php']) as $dir) {
                $allowed[] = self::rx($dir) . '/';
            }

            if (!empty($cfg['s_wellknown'])) {
                $allowed[] = '\.well-known/';
            }

            // Vorhandene Datei mit NICHT freigegebener Endung, die auch NICHT in einem freigegebenen
            // Pfad liegt -> 403. Nicht existierende Pfade (SEF-Routing) bleiben unberuehrt.
            $out[] = '        <!-- ' . self::xc(htp_tc('COM_HTPROTECT_HTC_S_TYPES1', 'Vorhandene Dateien nur mit freigegebener Endung ausliefern.')) . ' -->';
            $out[] = '        <rule name="HTP-typeshield" stopProcessing="true">';
            $out[] = '          <match url="' . self::xa('(?!.*\.(' . $extPattern . ')$)(?!^(' . implode('|', $allowed) . ')).*') . '" ignoreCase="true" />';
            $out[] = '          <conditions logicalGrouping="MatchAll">';
            $out[] = '            <add input="{REQUEST_FILENAME}" matchType="IsFile" />';
            $out[] = '          </conditions>';
            $out[] = '          <action type="CustomResponse" statusCode="403" statusReason="Forbidden" statusDescription="Blocked by HTProtect" />';
            $out[] = '        </rule>';
        }
    }

    private static function secRouter(&$out, $cfg)
    {
        if (empty($cfg['sef_rules'])) {
            return;
        }

        $out[] = '';
        $out[] = '    <!-- ' . self::xc(htp_tc('COM_HTPROTECT_IIS_R1', 'Joomla-Router: Joomlas eigene web.config bringt ihn ueblicherweise schon mit.')) . ' -->';
        $out[] = '    <!-- ' . self::xc(htp_tc('COM_HTPROTECT_IIS_R2', 'Nur einfuegen, falls SEF-URLs 404 liefern und noch keine Router-Regel existiert:')) . ' -->';
        $out[] = '    <!--';
        $out[] = '    <rewrite><rules>';
        $out[] = '      <rule name="Joomla-SEF" stopProcessing="true">';
        $out[] = '        <match url="(.*)" />';
        $out[] = '        <conditions logicalGrouping="MatchAll">';
        $out[] = '          <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />';
        $out[] = '          <add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />';
        $out[] = '        </conditions>';
        $out[] = '        <action type="Rewrite" url="index.php" />';
        $out[] = '      </rule>';
        $out[] = '    </rules></rewrite>';
        $out[] = '    -->';
    }

    // ─────────────────────────────────────────────────────────────────
    //  Regel-Bausteine
    // ─────────────────────────────────────────────────────────────────

    /** 403-Regel gegen die Match-URL (Pfad, ohne fuehrenden Slash, ohne Site-Praefix). */
    private static function ruleUrl(&$out, $name, $pattern, $comment)
    {
        if ($comment !== '') {
            $out[] = '        <!-- ' . self::xc($comment) . ' -->';
        }

        $out[] = '        <rule name="HTP-' . self::xa($name) . '" stopProcessing="true">';
        $out[] = '          <match url="' . self::xa($pattern) . '" ignoreCase="true" />';
        $out[] = '          <action type="CustomResponse" statusCode="403" statusReason="Forbidden" statusDescription="Blocked by HTProtect" />';
        $out[] = '        </rule>';
    }

    /** 403-Regel gegen die Match-URL mit waehlbarer Gross/Kleinschreibung. */
    private static function ruleUrlCI(&$out, $name, $pattern, $ignoreCase)
    {
        $out[] = '        <rule name="HTP-' . self::xa($name) . '" stopProcessing="true">';
        $out[] = '          <match url="' . self::xa($pattern) . '" ignoreCase="' . ($ignoreCase ? 'true' : 'false') . '" />';
        $out[] = '          <action type="CustomResponse" statusCode="403" statusReason="Forbidden" statusDescription="Blocked by HTProtect" />';
        $out[] = '        </rule>';
    }

    /** 403-Regel gegen den Query-String. */
    private static function ruleQuery(&$out, $name, $pattern, $comment)
    {
        if ($comment !== '') {
            $out[] = '        <!-- ' . self::xc($comment) . ' -->';
        }

        $out[] = '        <rule name="HTP-' . self::xa($name) . '" stopProcessing="true">';
        $out[] = '          <match url=".*" />';
        $out[] = '          <conditions logicalGrouping="MatchAll">';
        $out[] = '            <add input="{QUERY_STRING}" pattern="' . self::xa($pattern) . '" ignoreCase="true" />';
        $out[] = '          </conditions>';
        $out[] = '          <action type="CustomResponse" statusCode="403" statusReason="Forbidden" statusDescription="Blocked by HTProtect" />';
        $out[] = '        </rule>';
    }

    /** 403-Regel gegen die HTTP-Methode. */
    private static function ruleMethod(&$out, $name, $pattern, $comment)
    {
        if ($comment !== '') {
            $out[] = '        <!-- ' . self::xc($comment) . ' -->';
        }

        $out[] = '        <rule name="HTP-' . self::xa($name) . '" stopProcessing="true">';
        $out[] = '          <match url=".*" />';
        $out[] = '          <conditions logicalGrouping="MatchAll">';
        $out[] = '            <add input="{REQUEST_METHOD}" pattern="' . self::xa($pattern) . '" />';
        $out[] = '          </conditions>';
        $out[] = '          <action type="CustomResponse" statusCode="403" statusReason="Forbidden" statusDescription="Blocked by HTProtect" />';
        $out[] = '        </rule>';
    }

    // ─────────────────────────────────────────────────────────────────
    //  Helfer
    // ─────────────────────────────────────────────────────────────────

    /**
     * Apache-RewriteRule-Pfadmuster (ohne Praefix, ohne fuehrenden Slash) fuer die IIS-Match-URL
     * uebernehmen. IIS matcht bereits relativ zum Site-Root - ein fuehrendes '^' bleibt, kein
     * Praefix/Slash noetig.
     *
     * @param   string  $uri  Muster
     *
     * @return string
     */
    private static function uriToIis($uri)
    {
        return (string) $uri;
    }

    private static function head(&$out, $no, $title)
    {
        $out[] = '    <!-- ==== [' . self::xc($no) . '] ' . self::xc($title) . ' ==== -->';
    }

    /** Abschnitts-Ueberschrift INNERHALB der <rules> (als Kommentarzeile). */
    private static function rhead(&$out, $no, $title)
    {
        $out[] = '        <!-- ==== [' . self::xc($no) . '] ' . self::xc($title) . ' ==== -->';
    }

    private static function commentText($text)
    {
        return trim(preg_replace('/[\r\n]+/', ' ', (string) $text));
    }

    private static function prefixNote()
    {
        // Beibehalten fuer moegliche Erweiterungen; IIS matcht relativ zum Site-Root.
        return '';
    }

    private static function rx($value)
    {
        return preg_quote($value);
    }

    /** XML-ATTRIBUT-sicher escapen (& < > " '). */
    private static function xa($value)
    {
        return htmlspecialchars((string) $value, ENT_QUOTES | (defined('ENT_XML1') ? ENT_XML1 : 0), 'UTF-8');
    }

    /** XML-KOMMENTAR-sicher: '--' aufbrechen (in Kommentaren verboten) + < > escapen. */
    private static function xc($value)
    {
        $value = str_replace('--', '- -', (string) $value);

        return str_replace(array('<', '>'), array('&lt;', '&gt;'), $value);
    }
}
