<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * Nginx-Schild: erzeugt aus derselben Konfiguration wie das Apache-Schild
 * einen Regelblock fuer den server{}-Kontext von nginx.
 *
 * Konstruktionsprinzipien fuer Fehlerfreiheit:
 * - Alle Sperren laufen als if/set-Ketten im server-Kontext. Diese werden
 *   in der Server-Rewrite-Phase ausgewertet, also VOR der location-Auswahl
 *   - damit ist die Wirkung unabhaengig davon, an welcher Stelle des
 *   server{}-Blocks der Hoster (z. B. Plesk) den Block einbindet.
 * - Keine Direktiven aus optionalen Modulen (brotli, gzip_static):
 *   eine unbekannte Direktive laesst "nginx -t" scheitern. Vorkomprimierte
 *   Auslieferung wird darum bewusst NICHT erzeugt (Server/Optimizer
 *   erledigen die Kompression ohnehin selbst).
 * - Der Joomla-Router (try_files) liegt nur als auskommentierte Vorlage
 *   bei, weil die Server-Konfiguration des Hosters ihn meist schon
 *   enthaelt und doppelte location-Definitionen den Reload verhindern.
 */

defined('_JEXEC') or die;

class HtpNginx
{
    /**
     * Kompletten nginx-Regelblock erzeugen.
     *
     * @param   array  $cfg  Konfiguration (identisch zum Apache-Schild)
     *
     * @return string Leer bei unausgeglichenen Klammern (Paranoia-Check)
     */
    public static function build($cfg)
    {
        $out = array();
        $now = htp_local_time(time(), 'Y-m-d H:i:s');

        $out[] = '# ==========================================================================';
        $out[] = '#  ' . htp_tc('COM_HTPROTECT_NX_HEAD_TITLE', 'Nginx-Serverschild - erzeugt von HTProtect Server-Schild') . ' ' . HTP_VERSION;
        $out[] = '#  ' . htp_tc('COM_HTPROTECT_HTC_HEAD_DATE', 'Stand:') . ' ' . $now . ' | website-bereinigung.de';
        $out[] = '# --------------------------------------------------------------------------';
        $out[] = '#  ' . htp_tc('COM_HTPROTECT_NX_HEAD_PLESK1', 'EINBAU - Plesk:   Domain -> Hosting- & DNS-Einstellungen -> Apache- &');
        $out[] = '#                    ' . htp_tc('COM_HTPROTECT_NX_HEAD_PLESK2', 'nginx-Einstellungen -> "Zusätzliche nginx-Anweisungen"');
        $out[] = '#                    ' . htp_tc('COM_HTPROTECT_NX_HEAD_PLESK3', '-> kompletten Inhalt einfügen und übernehmen.');
        $out[] = '#  ' . htp_tc('COM_HTPROTECT_NX_HEAD_MANUAL', 'EINBAU - manuell: Datei in den server{}-Block der Site einbinden:');
        $out[] = '#                    include /pfad/zu/htprotect-nginx.conf;';
        $out[] = '#  ' . htp_tc('COM_HTPROTECT_NX_HEAD_RELOAD', 'Danach IMMER erst prüfen, dann neu laden:');
        $out[] = '#                    nginx -t && nginx -s reload';
        $out[] = '#  ' . htp_tc('COM_HTPROTECT_NX_HEAD_IMPORTANT1', 'WICHTIG: nginx liest diese Datei NICHT automatisch aus dem Webroot -');
        $out[] = '#  ' . htp_tc('COM_HTPROTECT_NX_HEAD_IMPORTANT2', 'ohne Einbindung in die Server-Konfiguration ist sie wirkungslos.');
        $out[] = '#  ' . htp_tc('COM_HTPROTECT_NX_HEAD_OWNRULES1', 'Die "Eigenen Regeln" der Komponente sind Apache-Syntax und werden');
        $out[] = '#  ' . htp_tc('COM_HTPROTECT_NX_HEAD_OWNRULES2', 'hier bewusst nicht übernommen.');
        $out[] = '# ==========================================================================';
        $out[] = '# htprotect-nginx:begin v=' . HTP_VERSION . ' cfg=' . HtpBuilder::configHash($cfg) . ' written=' . $now;
        $out[] = '';

        self::secBasics($out, $cfg);
        self::secHeaders($out, $cfg);
        self::secFilters($out, $cfg);
        self::secExploits($out, $cfg);
        self::secCanonical($out, $cfg);
        self::secLockdown($out, $cfg);
        self::secShield($out, $cfg);
        self::secPerformance($out, $cfg);
        self::secRouter($out, $cfg);

        $out[] = '# htprotect-nginx:end';
        $out[] = '';

        $content = implode("\n", $out);

        // Paranoia-Check: geschweifte Klammern muessen ausgeglichen sein
        // (Regex-Quantoren wie {0,2} enthalten immer Paare)
        if (substr_count($content, '{') !== substr_count($content, '}')) {
            return '';
        }

        return $content;
    }

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

    private static function secBasics(&$out, $cfg)
    {
        self::head($out, '1', htp_tc('COM_HTPROTECT_HTC_HEAD_BASICS', 'Grundlagen'));

        $out[] = '# ' . htp_tc('COM_HTPROTECT_NX_FASTCGI1', 'nginx reicht den Authorization-Header automatisch an PHP-FPM durch -');
        $out[] = '# ' . htp_tc('COM_HTPROTECT_NX_FASTCGI2', 'ein FastCGI-Workaround wie unter Apache ist hier nicht nötig.');

        if (!empty($cfg['apache_options'])) {
            $out[] = '';
            $out[] = '# ' . htp_tc('COM_HTPROTECT_NX_DIRLIST1', 'Verzeichnislisten und Versions-Banner aus');
            $out[] = '# ' . htp_tc('COM_HTPROTECT_NX_DIRLIST2', '(bei "duplicate"-Fehler in nginx -t: Zeile entfernen, ist dann bereits gesetzt)');
            $out[] = 'autoindex off;';
            $out[] = 'server_tokens off;';
        }

        $out[] = '';
    }

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

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

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

        if (!empty($cfg['h_referrer']) && is_string($cfg['h_referrer'])) {
            $lines[] = 'add_header Referrer-Policy "' . $cfg['h_referrer'] . '" always;';
        }

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

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

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

        if (!empty($cfg['h_poweredby'])) {
            $lines[] = 'fastcgi_hide_header X-Powered-By;';
            $lines[] = 'fastcgi_hide_header X-Content-Powered-By;';
            $lines[] = 'proxy_hide_header X-Powered-By;';
        }

        if (empty($lines)) {
            return;
        }

        self::head($out, '3', htp_tc('COM_HTPROTECT_HTC_HEAD_HEADERS', 'Antwort-Header'));

        $out[] = '# ' . htp_tc('COM_HTPROTECT_NX_HEADERS1', 'Hinweis: definiert eine location im server-Block eigene add_header-Zeilen,');
        $out[] = '# ' . htp_tc('COM_HTPROTECT_NX_HEADERS2', 'erbt sie diese hier nicht (nginx-Eigenheit) - dann dort wiederholen.');

        foreach ($lines as $line) {
            $out[] = $line;
        }

        // SVG-Inline-JavaScript unterbinden (wie Joomla-Standard). In nginx
        // braucht das eine eigene location (sonst wuerde ein server-weiter
        // add_header alle Antworten treffen) - bei Bedarf einkommentieren,
        // NICHT doppelt zu einer bestehenden location definieren.
        if (!empty($cfg['h_nosniff'])) {
            $out[] = '';
            $out[] = '# ' . htp_tc('COM_HTPROTECT_NX_SVGCSP', 'SVG-Dateien: Inline-JavaScript unterbinden (eigene location, bei Bedarf aktivieren):');
            $out[] = '#location ~* "\.svg$" {';
            $out[] = '#    add_header Content-Security-Policy "script-src \'none\'" always;';
            $out[] = '#}';
        }

        $out[] = '';
    }

    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::head($out, '4', htp_tc('COM_HTPROTECT_HTC_HEAD_FILTERS', 'Anfrage-Filter'));

        $p = self::prefix($cfg);

        if ($methods) {
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_F_METHODS', 'Diagnose-Methoden gehören nicht in den Produktivbetrieb');
            $out[] = 'if ($request_method ~* "^(TRACE|TRACK|DEBUG)$") { return 403; }';
            $out[] = '';
        }

        if ($query) {
            // Jede Bedingung reicht allein (ODER-Kette wie unter Apache)
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_F_QUERY', 'Injektions- und Ausspäh-Muster im Query-String abweisen');
            $out[] = 'if ($query_string ~ "%00") { return 403; }';
            $out[] = 'if ($query_string ~* "(\.\./|\.\.%2f|%2e%2e/|%2e%2e%2f)") { return 403; }';
            $out[] = 'if ($query_string ~* "(data|expect|glob|ogg|phar|php|zlib)(://|%3a%2f%2f)") { return 403; }';
            $out[] = 'if ($query_string ~* "base64_(en|de)code\(") { return 403; }';
            $out[] = 'if ($query_string ~* "(<|%3C)script") { return 403; }';
            $out[] = 'if ($query_string ~ "proc/sel[f]/enviro[n]") { return 403; }';
            $out[] = 'if ($query_string ~ "mosConfig_[a-zA-Z_]{1,24}(=|%3D)") { return 403; }';
            $out[] = 'if ($query_string ~ "GLOBALS(=|\[|%[0-9A-Z]{0,2})") { return 403; }';
            $out[] = 'if ($query_string ~ "_REQUEST(=|\[|%[0-9A-Z]{0,2})") { return 403; }';
            $out[] = 'if ($query_string ~ "^=PHP[0-9A-Fa-f\-]{10,}") { return 403; }';
            $out[] = '';
        }

        if ($rfi) {
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_F_RFI', 'Fremde URLs oder Pfad-Klettern als GET-Parameterwerte unterbinden');
            $out[] = 'set $htpx_rfi "";';
            $out[] = 'if ($query_string ~* "=(https?|ftps?)(://|%3a%2f%2f)") { set $htpx_rfi "R"; }';
            $out[] = 'if ($query_string ~ "=(\.\.//?)+") { set $htpx_rfi "R"; }';
            $out[] = 'if ($request_method != GET) { set $htpx_rfi ""; }';
            $out[] = 'if ($htpx_rfi = "R") { return 403; }';
            $out[] = '';
        }

        if ($noise) {
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_F_WPNOISE', 'WordPress-Scanrauschen sofort abweisen (spart Joomla-Bootstraps)');
            $out[] = 'if ($uri ~* "^' . $p . '/(wp-admin|wp-content|wp-includes|wp-json)(/|$)") { return 403; }';
            $out[] = 'if ($uri ~* "^' . $p . '/(wp-login|wp-signup|wp-cron|xmlrpc)\.php") { return 403; }';
            $out[] = '';
        }
    }

    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::head($out, '4b', htp_tc('COM_HTPROTECT_HTC_HEAD_EXPLOIT', 'Exploit-Schild'));

        $p = self::prefix($cfg);

        if ($apiBlock) {
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_APIBLOCK_UNUSED', 'Schnittstellen-Anwendung (/api) komplett sperren (ungenutzt)');
            $out[] = 'if ($uri ~* "^' . $p . '/api(/|$)") { return 403; }';
            $out[] = '';
        }

        $idx = 0;

        foreach ($sigs as $sig) {
            $front = !empty($sig['frontend_only']);

            if (!empty($sig['qs'])) {
                $out[] = '# sig:' . $sig['id'] . ' ' . self::commentText(HtpDefs::localized($sig, 'label'));

                if ($front) {
                    $var = '$htpx_x' . $idx;
                    $out[] = 'set ' . $var . ' "";';
                    $out[] = 'if ($args ~* "' . $sig['qs'] . '") { set ' . $var . ' "B"; }';
                    $out[] = 'if ($uri ~* "^' . $p . '/administrator/") { set ' . $var . ' ""; }';
                    $out[] = 'if (' . $var . ' = "B") { return 403; }';
                    $idx++;
                } else {
                    $out[] = 'if ($args ~* "' . $sig['qs'] . '") { return 403; }';
                }

                $out[] = '';
            }

            if (!empty($sig['uri'])) {
                $pattern = self::uriToNginx($sig['uri'], $p);
                $out[] = '# sig:' . $sig['id'] . ' ' . self::commentText(HtpDefs::localized($sig, 'label')) . ' ' . htp_tc('COM_HTPROTECT_HTC_SIG_PATH', '(Pfad)');
                $out[] = 'if ($uri ~* "' . $pattern . '") { return 403; }';
                $out[] = '';
            }
        }
    }

    /**
     * Apache-RewriteRule-Pfadmuster (ohne Praefix, ohne fuehrenden Slash)
     * fuer nginx an $uri anpassen (mit Praefix und fuehrendem Slash).
     *
     * @param   string  $uri     Muster
     * @param   string  $prefix  Regex-escaped Site-Praefix
     *
     * @return string
     */
    private static function uriToNginx($uri, $prefix)
    {
        if (strpos($uri, '^') === 0) {
            return '^' . $prefix . '/' . substr($uri, 1);
        }

        return $uri;
    }

    /**
     * Kommentartext einzeilig machen.
     *
     * @param   string  $text  Rohtext
     *
     * @return string
     */
    private static function commentText($text)
    {
        return trim(preg_replace('/[\r\n]+/', ' ', (string) $text));
    }

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

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

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

        $host = HtpBuilder::canonicalHost($cfg);

        if ($https) {
            $target = ($www !== 0 || $cfg['canonical_host'] !== '') && $host !== '' ? $host : '$host';
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_C_HTTPS', 'Unverschlüsselte Aufrufe dauerhaft auf HTTPS heben');
            $out[] = '# ' . htp_tc('COM_HTPROTECT_NX_C_XFP', '(X-Forwarded-Proto verhindert Schleifen hinter Proxys/Load-Balancern)');
            $out[] = 'set $htpx_ssl "";';
            $out[] = 'if ($scheme != "https") { set $htpx_ssl "R"; }';
            $out[] = 'if ($http_x_forwarded_proto = "https") { set $htpx_ssl ""; }';
            $out[] = 'if ($htpx_ssl = "R") { return 301 https://' . $target . '$request_uri; }';

            if ($target !== '$host') {
                $out[] = '';
                $out[] = '# ' . htp_tc('COM_HTPROTECT_NX_C_HOST', 'Abweichender Hostname -> kanonische Adresse');
                $out[] = 'if ($host != "' . $host . '") { return 301 https://' . $host . '$request_uri; }';
            }

            $out[] = '';
        } elseif ($www !== 0 && $host !== '') {
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_C_HOST', 'Hostnamen vereinheitlichen, Verschlüsselung des Aufrufs beibehalten');
            $out[] = 'set $htpx_proto $scheme;';
            $out[] = 'if ($http_x_forwarded_proto = "https") { set $htpx_proto "https"; }';
            $out[] = 'if ($host != "' . $host . '") { return 301 $htpx_proto://' . $host . '$request_uri; }';
            $out[] = '';
        }

    }

    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::head($out, '6', htp_tc('COM_HTPROTECT_HTC_HEAD_LOCKDOWN', 'Sperren für interne Bereiche'));

        $p = self::prefix($cfg);

        if ($manifests) {
            $out[] = '# ' . htp_tc('COM_HTPROTECT_NX_L_MANIFEST', 'Erweiterungs-/Kern-Manifeste (.xml) verstecken (Versions-Fingerprinting)');
            $out[] = 'if ($uri ~* "^' . $p . '/(administrator/)?(components|libraries|modules|plugins|templates)/.+\.xml$") { return 403; }';
            $out[] = 'if ($uri ~* "^' . $p . '/administrator/manifests(/|$)") { return 403; }';
            $out[] = '';
        }

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

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

            sort($dirs);
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_L_INTERNAL', 'Interne Verzeichnisse sind über das Web tabu');
            $out[] = 'if ($uri ~* "^' . $p . '/(' . implode('|', $dirs) . ')(/|$)") { return 403; }';
            $out[] = '';
        }

        if ($dotfiles) {
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_L_DOTFILES', 'Versteckte Dateien und Verzeichnisse (.git, .env, .htpasswd ...)');

            if (!empty($cfg['s_wellknown'])) {
                $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_L_WELLKNOWN', '.well-known bleibt für Zertifikats-Validierung erreichbar');
                $out[] = 'set $htpx_dot "";';
                $out[] = 'if ($uri ~ "(^|/)\.") { set $htpx_dot "D"; }';
                $out[] = 'if ($uri ~ "^' . $p . '/\.well-known(/|$)") { set $htpx_dot ""; }';
                $out[] = 'if ($htpx_dot = "D") { return 403; }';
            } else {
                $out[] = 'if ($uri ~ "(^|/)\.") { return 403; }';
            }

            $out[] = '';
        }

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

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

            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_L_SENSNAME', 'Namentlich bekannte sensible Dateien');
            $out[] = 'if ($uri ~ "/(' . $names . ')$") { return 403; }';
            $out[] = '';
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_L_SENSEXT', 'Riskante Endungen: Sicherungen, Schlüssel, Datenbanken, Logs, Konfigurationen');
            $out[] = 'if ($uri ~* "' . HtpBuilder::SENSITIVE_EXT . '") { return 403; }';
            $out[] = '';
        }
    }

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

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

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

        $p = self::prefix($cfg);

        if ($php) {
            // Freigaben als negative Lookaheads direkt nach dem Pfadanfang
            $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[] = '# ' . htp_tc('COM_HTPROTECT_HTC_S_PHP', 'PHP läuft nur über freigegebene Einstiegspunkte - alles andere: 403');
            $out[] = '# ' . htp_tc('COM_HTPROTECT_NX_S_PHP', '($uri ist bereits URL-dekodiert - %2e-Tricks laufen ins Leere)');
            $out[] = 'if ($uri ~* "^' . $p . '/' . $ahead . '.*' . HtpBuilder::PHP_RULE_PATTERN . '") { return 403; }';
            $out[] = '';
        }

        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/';
            }

            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_S_TYPES1', 'Vorhandene Dateien werden nur mit freigegebener Endung ausgeliefert.');
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_S_TYPES2', 'Nicht existierende Pfade bleiben unberührt (SEF-Routing).');
            $out[] = 'set $htpx_type "";';
            $out[] = 'if (-f $request_filename) { set $htpx_type "F"; }';
            $out[] = 'if ($uri ~* "\.(' . $extPattern . ')$") { set $htpx_type ""; }';
            $out[] = 'if ($uri ~* "^' . $p . '/(' . implode('|', $allowed) . ')") { set $htpx_type ""; }';
            $out[] = 'if ($htpx_type = "F") { return 403; }';
            $out[] = '';
        }
    }

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

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

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

        if ($compress) {
            // text/html komprimiert nginx immer - in gzip_types ausgelassen,
            // sonst meldet nginx "duplicate MIME type"
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_D_COMPRESS', 'Textbasierte Inhalte komprimiert übertragen');
            $out[] = 'gzip on;';
            $out[] = 'gzip_vary on;';
            $out[] = 'gzip_comp_level 5;';
            $out[] = 'gzip_min_length 256;';
            $out[] = 'gzip_proxied any;';
            $out[] = 'gzip_types application/javascript application/json application/ld+json'
                . ' application/manifest+json application/rss+xml application/vnd.ms-fontobject'
                . ' application/wasm application/x-javascript application/xhtml+xml application/xml'
                . ' font/otf font/ttf image/svg+xml text/css text/javascript text/plain text/vtt text/xml;';
            $out[] = '';
        }

        if ($expires) {
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_D_EXPIRES1', 'Browser-Cache: lange Laufzeiten nur für statische Inhalte.');
            $out[] = '# ' . htp_tc('COM_HTPROTECT_NX_D_EXPIRES2', 'HTML/JSON bekommen bewusst keine Laufzeit (immer frisch).');
            $out[] = '# ' . htp_tc('COM_HTPROTECT_NX_D_EXPIRES3', 'Greift nur, wenn keine frühere location der Server-Konfiguration');
            $out[] = '# ' . htp_tc('COM_HTPROTECT_NX_D_EXPIRES4', 'dieselben Dateien bereits behandelt (dann gilt deren Einstellung).');
            $out[] = 'location ~* "\.(avif|bmp|css|eot|gif|ico|jpe?g|js|mjs|mp3|mp4|oga|ogg|ogv|opus|otf|png|svg|tiff?|ttf|wasm|webm|webp|woff2?)$" {';
            $out[] = '    expires 1y;';
            $out[] = '    access_log off;';
            $out[] = '    try_files $uri =404;';
            $out[] = '}';
            $out[] = '';
        }

        if ($etag) {
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_D_ETAG', 'ETags abschalten (konsistentes Caching hinter Load-Balancern)');
            $out[] = 'etag off;';
            $out[] = '';
        }
    }

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

        self::head($out, '9', htp_tc('COM_HTPROTECT_HTC_HEAD_ROUTER', 'Joomla-Router'));

        $out[] = '# ' . htp_tc('COM_HTPROTECT_NX_R1', 'Die Weiterleitung an den Joomla-Router ist in der Server-Konfiguration');
        $out[] = '# ' . htp_tc('COM_HTPROTECT_NX_R2', 'des Hosters meist schon vorhanden (Plesk: automatisch). Nur einkommentieren,');
        $out[] = '# ' . htp_tc('COM_HTPROTECT_NX_R3', 'falls SEF-URLs 404 liefern UND keine "location /" definiert ist -');
        $out[] = '# ' . htp_tc('COM_HTPROTECT_NX_R4', 'eine doppelte location-Definition verhindert sonst den Reload.');

        if (!empty($cfg['api_router'])) {
            $out[] = '#location /api/ {';
            $out[] = '#    try_files $uri $uri/ /api/index.php?$args;';
            $out[] = '#}';
        }

        $out[] = '#location / {';
        $out[] = '#    try_files $uri $uri/ /index.php?$args;';
        $out[] = '#}';
        $out[] = '';
    }

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

    private static function head(&$out, $no, $title)
    {
        $out[] = '# --------------------------------------------------------------------------';
        $out[] = '#  [' . $no . '] ' . $title;
        $out[] = '# --------------------------------------------------------------------------';
    }

    private static function prefix($cfg)
    {
        return $cfg['site_path'] === '' ? '' : self::rx($cfg['site_path']);
    }

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