<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * Passwortschutz fuer /administrator (.htaccess + .htpasswd).
 * Nutzt dieselben Marker wie die fruehere Plugin-Edition, erkennt also
 * auch dort eingerichtete Schutze.
 */

defined('_JEXEC') or die;

class HtpGuard
{
    const MARK_BEGIN = '# ── htprotect Password Protection ──';
    const MARK_END   = '# ── /htprotect Password Protection ──';

    // Option 2 (Alternative zum Passwortschutz): Cookie-Gate ueber einen geheimen Zugangs-String.
    // /administrator/?<String> setzt ein Secure+HttpOnly-Cookie und leitet ohne Query um; danach
    // ist das Backend normal erreichbar. Ohne gueltiges Cookie -> 404 (Backend wirkt "nicht da").
    // Kein Passwort-Prompt, keine .htpasswd. Mechanik gespiegelt vom Notfall-Modus (builder.php).
    const MARK_GATE_BEGIN = '# ── htprotect Access-String Gate ──';
    const MARK_GATE_END   = '# ── /htprotect Access-String Gate ──';
    const GATE_COOKIE     = 'htp_ag';

    // Marker der EIGENEN administrator/cache/.htaccess-Ausnahme (statische Bilder trotz
    // Backend-Passwortschutz). Nur eine per Marker als unsere erkannte Datei wird von uns
    // angelegt/aktualisiert/entfernt - eine fremde cache/.htaccess bleibt unangetastet.
    const CACHE_MARK = '# htprotect cache-image exception';

    /**
     * EasyCalcCheck Plus: ist der Backend-Login per Token geschuetzt? Erkennt das
     * Plugin plg_system_easycalccheckplus mit aktivierter Option "Backend mit
     * Token schuetzen" (backendProtection) UND gesetztem Token. Liest dies aus
     * #__extensions; faellt nie hart aus (try/catch -> false).
     *
     * @return boolean
     */
    public static function eccBackendProtected()
    {
        $db = htp_db();

        if (!$db) {
            return false;
        }

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName(array('enabled', 'params')))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('easycalccheckplus'))
                ->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
            $db->setQuery($query);
            $row = $db->loadAssoc();
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }

        if (!is_array($row)) {
            return false;
        }

        return self::eccProtectionActive(
            isset($row['enabled']) ? $row['enabled'] : 0,
            isset($row['params']) ? $row['params'] : ''
        );
    }

    /**
     * Reine, testbare Auswertung der ECC+-Konfiguration: Plugin aktiv (enabled=1)
     * UND Option "Backend mit Token schuetzen" an (backendProtection=1) UND ein
     * Token gesetzt -> der Backend-Login ist per Token geschuetzt.
     *
     * @param   mixed  $enabled  enabled-Spalte (0/1)
     * @param   mixed  $params   params (JSON-String ODER bereits dekodiertes Array)
     *
     * @return boolean
     */
    public static function eccProtectionActive($enabled, $params)
    {
        if ((int) $enabled !== 1) {
            return false;
        }

        if (is_string($params)) {
            $params = json_decode($params, true);
        }

        if (!is_array($params)) {
            return false;
        }

        $backend = isset($params['backendProtection']) ? (int) $params['backendProtection'] : 0;
        $token   = isset($params['token']) ? trim((string) $params['token']) : '';

        return $backend === 1 && $token !== '';
    }

    /**
     * Aktueller Zustand des Backend-Schutzes.
     *
     * @return array ['active' => bool, 'user' => string, 'writable' => bool]
     */
    public static function status()
    {
        $admin    = htp_admin_root();
        // Public-Folder: die .htaccess, die die /administrator-URL schuetzt, wird aus dem
        // WEB-Docroot ausgeliefert (htp_web_admin_root = public/administrator), NICHT aus dem
        // Code-administrator oberhalb des Webroots. Die .htpasswd bleibt bewusst bei htp_admin_root
        // (Apache liest sie ueber den absoluten AuthUserFile-Pfad; oberhalb public = sicherer).
        // Klassisch sind beide identisch -> unveraendert.
        $htaccess = htp_web_admin_root() . '/.htaccess';
        $htpasswd = $admin . '/.htpasswd';
        $active   = false;
        $user     = '';
        $mode     = '';   // 'password' | 'string', wenn aktiv

        $foreignAuth = false;

        // In der vorhandenen administrator/.htaccess referenzierte .htpasswd -
        // kann an einem ANDEREN Ort liegen (z. B. geteilt parallel zu allen
        // Sites). "auth_usable" = es gibt eine vorhandene Passwortdatei, auf die
        // sich auch der Notfall-Modus stuetzen kann, ohne neu zu setzen.
        $authFile   = self::detectAuthUserFile();
        $authUsable = ($authFile !== '' && @is_file($authFile)) || is_file($htpasswd);

        if (is_file($htaccess)) {
            $content = (string) @file_get_contents($htaccess);

            if (strpos($content, self::MARK_GATE_BEGIN) !== false) {
                // Option 2: Cookie-Gate ueber Zugangs-String (kein .htpasswd, kein Passwort-Prompt).
                $active = true;
                $mode   = 'string';
            } elseif (strpos($content, self::MARK_BEGIN) !== false) {
                $active = $authUsable;

                if ($active) {
                    $mode = 'password';
                }
            } elseif (preg_match('/^[ \t]*(AuthType[ \t]+Basic|AuthUserFile|Require[ \t]+valid-user)/mi', $content)) {
                // Fremde Basic-Auth direkt in der .htaccess (nicht von HTProtect)
                $foreignAuth = true;
            }
        }

        if (($active && $mode !== 'string') || $foreignAuth) {
            $src  = ($authFile !== '' && @is_file($authFile)) ? $authFile : $htpasswd;
            $line = (string) @file_get_contents($src);
            $pos  = strpos($line, ':');

            if ($pos !== false) {
                $user = substr($line, 0, $pos);
            }
        }

        $writable = is_writable($admin)
            && (!is_file($htaccess) || is_writable($htaccess))
            && (!is_file($htpasswd) || is_writable($htpasswd));

        // Public-Folder: die Schutz-.htaccess liegt im SERVED Administrator (public/administrator),
        // NICHT im Code-Admin. Existiert sie noch nicht (Ersteinrichtung), muss die Schreibbarkeit
        // GENAU DIESES Ordners geprueft werden - sonst meldet der Status faelschlich "schreibbar",
        // obwohl das Anlegen der .htaccess spaeter scheitert. Klassisch identisch (dirname==$admin).
        if ($writable && !is_file($htaccess) && function_exists('htp_is_public_folder') && htp_is_public_folder()) {
            $writable = is_writable(dirname($htaccess));
        }

        return array(
            'active'       => $active,
            'mode'         => $mode,
            'user'         => trim($user),
            'writable'     => $writable,
            'foreign_auth' => $foreignAuth,
            'auth_file'    => $authFile,
            'auth_usable'  => $authUsable,
        );
    }

    /**
     * Die in administrator/.htaccess referenzierte AuthUserFile-.htpasswd
     * auslesen (eigener ODER fremder Block; ein anderer Speicherort wird so
     * uebernommen). Liefert den absoluten Pfad oder '' (keine aktive Direktive).
     *
     * @return string
     */
    public static function detectAuthUserFile()
    {
        $htaccess = htp_web_admin_root() . '/.htaccess';

        if (!is_file($htaccess)) {
            return '';
        }

        $content = (string) @file_get_contents($htaccess);

        // Nur aktive (nicht auskommentierte) AuthUserFile-Zeile, optional in
        // Anfuehrungszeichen. Erste gewinnt (entspricht Apache-Verhalten genug).
        if (preg_match('/^[ \t]*AuthUserFile[ \t]+"?([^"\r\n]+?)"?[ \t]*$/mi', $content, $m)) {
            $path = rtrim(str_replace('\\', '/', trim($m[1])), '/');

            if ($path !== '' && strpos($path, "\0") === false) {
                return $path;
            }
        }

        return '';
    }

    /**
     * Erkennt einen extern eingerichteten Backend-Schutz (serverseitige
     * Basic-Auth im Hosting-Panel/vHost ODER eine fremde .htaccess), indem
     * das Backend per HTTP angefragt wird: Antwortet /administrator mit 401,
     * ist ein Passwortschutz aktiv - unabhaengig davon, wie er eingerichtet
     * wurde. So wird "geschuetzt, wie auch immer" zuverlaessig erkannt.
     *
     * @return array ['protected' => bool, 'status' => int (0 = nicht pruefbar)]
     */
    public static function probeExternal()
    {
        if (!class_exists('HtpProbe') || !HtpProbe::available()) {
            return array('protected' => false, 'status' => 0);
        }

        $res    = HtpProbe::fetch(htp_site_url() . 'administrator/index.php', false);
        $status = (int) $res['status'];

        return array('protected' => $status === 401, 'status' => $status);
    }

    /**
     * Schutz einrichten oder Zugangsdaten erneuern.
     *
     * Nach dem Schreiben laeuft ein HTTP-Selbsttest gegen das Backend:
     * 500er-Antworten fuehren zum automatischen Rollback auf den
     * vorherigen Zustand (manche Hoster erlauben keine Auth-Direktiven
     * per .htaccess oder Apache sieht andere Dateipfade als PHP).
     * Schlaegt die Anmeldung fehl, werden automatisch alternative
     * AuthUserFile-Pfade aus der Apache-Sicht (SCRIPT_FILENAME,
     * DOCUMENT_ROOT) durchprobiert - hilft bei chroot-Umgebungen.
     *
     * @param   string  $username   Benutzername
     * @param   string  $password   Passwort (roh)
     * @param   string  $manualDir  Optional: manuell vorgegebener Serverpfad des administrator-Ordners
     *
     * @return array ['success' => bool, 'message' => string, 'warnings' => array, 'auth_dir' => string]
     */
    public static function setup($username, $password, $manualDir = '', $replaceAll = false)
    {
        $username  = trim((string) $username);
        $password  = (string) $password;
        $manualDir = self::effectiveManualDir($manualDir);

        if ($username === '' || $password === '') {
            return self::fail(htp_t('COM_HTPROTECT_GUARD_ERR_EMPTY'));
        }

        if (!preg_match('/^[a-zA-Z0-9._@\-]+$/', $username)) {
            return self::fail(htp_t('COM_HTPROTECT_GUARD_ERR_USERCHARS'));
        }

        if (strlen($password) < 8) {
            return self::fail(htp_t('COM_HTPROTECT_GUARD_ERR_PWLEN'));
        }

        $hash = self::hash($password);

        if ($hash === false) {
            return self::fail(htp_t('COM_HTPROTECT_GUARD_ERR_NOHASH'));
        }

        $admin    = str_replace('\\', '/', htp_admin_root());
        // .htaccess in den WEB-ausgelieferten Admin (public/administrator im PF-Modus), damit
        // Apache sie fuer die /administrator-URL wirklich liest; klassisch == $admin. Die .htpasswd
        // (unten, ueber $admin) bleibt oberhalb des Webroots.
        $htaccess = str_replace('\\', '/', htp_web_admin_root()) . '/.htaccess';

        // Zielort der .htpasswd bestimmen:
        //   manuell vorgegeben  >  bereits genutzter (auch FREMDER) anderer Ort  >  Standard
        // Liegt bereits ein Schutz mit einer .htpasswd an anderer Stelle (z. B.
        // geteilt parallel zu allen Sites), wird GENAU DIESER Ort uebernommen.
        $detected = self::detectAuthUserFile();
        $adopt    = ($manualDir === '' && $detected !== '' && @is_file($detected) && self::dirOf($detected) !== $admin);

        if ($manualDir !== '') {
            $htpasswd = $manualDir . '/.htpasswd';
            $isOwn    = (self::cleanDir($manualDir) === $admin);
        } elseif ($adopt) {
            $htpasswd = $detected;
            $isOwn    = false;
        } else {
            $htpasswd = $admin . '/.htpasswd';
            $isOwn    = true;
        }

        // Vorherigen Zustand fuer den Rollback festhalten
        $origHtaccess = is_file($htaccess) ? (string) @file_get_contents($htaccess) : null;
        $origHtpasswd = is_file($htpasswd) ? (string) @file_get_contents($htpasswd) : null;

        // Eigene Datei: eine Zeile. Uebernommene/geteilte Datei: nur die Zeile
        // DIESES Nutzers ersetzen/ergaenzen, alle anderen Eintraege bleiben.
        // ERNEUERUNG eines bereits aktiven eigenen Schutzes (isOwnActive) NICHT einzeilig
        // schreiben, sonst wuerden ueber "Weitere Zugaenge" angelegte Mit-Nutzer geloescht;
        // dann greift der Merge-Branch und ersetzt nur den Primaer-Login. Erst-Setup bleibt
        // einzeilig (isOwnActive false vor dem ersten Schreiben) -> keine Altlast-Wiederkehr.
        // $replaceAll (Batch „neu setzen"): auf der EIGENEN Datei einzeilig schreiben -> genau EINE Kombi,
        // alle bestehenden HTProtect-Zugaenge weg. Auf einer fremden/geteilten .htpasswd wird das NIE
        // erzwungen (dann bleibt der Merge-Zweig: nur die Zeile dieses Nutzers). Bei fehlgeschlagenem
        // Login-Selbsttest rollt setup() ohnehin auf den urspruenglichen Stand zurueck -> nie ungeschuetzt.
        $ownExclusive = $isOwn && ($replaceAll || !self::isOwnActive());

        if (@file_put_contents($htpasswd, self::htpasswdWithUser($origHtpasswd, $username, $hash, $ownExclusive), LOCK_EX) === false) {
            // MIGRATIONS-ROBUSTHEIT (Schreib-Fallback): Der gewaehlte Ort ist nicht beschreibbar
            // (z. B. ein aus einem Umzug uebernommener/gemerkter Sonderpfad, der zwar existiert,
            // aber nicht dem PHP-Nutzer gehoert). Bevor wir aufgeben, auf den Standard
            // /administrator/.htpasswd zurueckfallen - dort schreibt HTProtect regulaer.
            $std = $admin . '/.htpasswd';

            if ($htpasswd === $std) {
                return self::fail(htp_t('COM_HTPROTECT_GUARD_ERR_HTPASSWD_WRITE'));
            }

            $stdOrig = is_file($std) ? (string) @file_get_contents($std) : null;

            if (@file_put_contents($std, self::htpasswdWithUser($stdOrig, $username, $hash, true), LOCK_EX) === false) {
                return self::fail(htp_t('COM_HTPROTECT_GUARD_ERR_HTPASSWD_WRITE'));
            }

            // Ab hier gilt der Standardort - inkl. korrekter Rollback-Basis.
            $htpasswd     = $std;
            $isOwn        = true;
            $origHtpasswd = $stdOrig;
        }

        @chmod($htpasswd, 0644);

        // BEIDE eigenen Bloecke raus - so entfernt ein Wechsel Option 2 (Cookie-Gate) -> Option 1
        // (Passwort) das Gate, statt es neben dem Auth-Block stehen zu lassen (sonst wuerde die
        // 404-Regel auch authentifizierte Nutzer blocken).
        $existing = $origHtaccess !== null ? self::stripGateBlock(self::stripBlock($origHtaccess)) : '';

        // Moegliche AuthUserFile-Pfade: manuell > PHP-Sicht > Apache-Sicht
        $candidates = self::candidateAuthPaths($htpasswd, $manualDir);
        $authPath   = $candidates[0];

        if (!self::writeBlock($htaccess, $existing, self::block($authPath, true))) {
            self::restoreFile($htpasswd, $origHtpasswd);

            return self::fail(htp_t('COM_HTPROTECT_GUARD_ERR_HTACCESS_WRITE'));
        }

        // Passwort-Modus committen (Option 1): einen evtl. gemerkten Cookie-Gate-Modus (Option 2)
        // aufheben, damit die Selbstheilung (rebuildBlock) den richtigen Block wiederherstellt.
        if (class_exists('HtpConfig')) {
            HtpConfig::saveKeys(array('guard_mode' => 'password', 'guard_string' => ''));
        }

        // ── Selbsttest ──
        $okMessage = htp_t('COM_HTPROTECT_GUARD_OK', htp_esc($username));

        if (!HtpProbe::available()) {
            // Block wurde MIT der FilesMatch-Ausnahme geschrieben (oben) -> die Cache-
            // Bilder-Ausnahme im selben, ungetesteten Zustand mitziehen.
            self::syncCacheImageException(true);

            return array(
                'success'  => true,
                'message'  => $okMessage,
                'warnings' => array(htp_t('COM_HTPROTECT_GUARD_WARN_NOHTTP')),
                'auth_dir' => self::dirOf($authPath),
            );
        }

        $adminUrl      = htp_site_url() . 'administrator/index.php';
        $warnings      = array();
        $withException = true;
        $probe         = HtpProbe::fetch($adminUrl, false);
        $status        = (int) $probe['status'];

        if ($status >= 500) {
            // Zweiter Versuch ohne die Update-Ausnahme - manche Server
            // stolpern ueber FilesMatch-/IfModule-Konstrukte in .htaccess.
            $withException = false;
            self::writeBlock($htaccess, $existing, self::block($authPath, false));
            $probe  = HtpProbe::fetch($adminUrl, false);
            $status = (int) $probe['status'];

            if ($status >= 500) {
                self::restoreFile($htaccess, $origHtaccess);
                self::restoreFile($htpasswd, $origHtpasswd);

                return self::fail(htp_t('COM_HTPROTECT_GUARD_ROLLBACK_500', $status));
            }

            $warnings[] = htp_t('COM_HTPROTECT_GUARD_WARN_NOEXCEPTION');
        }

        if ($status === 401) {
            // Passwortabfrage aktiv - jetzt alle Pfad-Kandidaten durchprobieren,
            // bis die Anmeldung mit den neuen Zugangsdaten funktioniert.
            $working = '';

            foreach ($candidates as $i => $candidate) {
                if ($i > 0) {
                    self::writeBlock($htaccess, $existing, self::block($candidate, $withException));
                    $check = HtpProbe::fetch($adminUrl, false);

                    if ((int) $check['status'] !== 401) {
                        continue;
                    }
                }

                $login       = HtpProbe::fetch($adminUrl, false, array($username, $password));
                $loginStatus = (int) $login['status'];

                if ($loginStatus !== 0 && $loginStatus !== 401 && $loginStatus < 500) {
                    $working = $candidate;
                    break;
                }
            }

            if ($working === '') {
                self::restoreFile($htaccess, $origHtaccess);
                self::restoreFile($htpasswd, $origHtpasswd);

                $tried = array();

                foreach ($candidates as $candidate) {
                    $tried[] = htp_esc(self::dirOf($candidate));
                }

                return self::fail(htp_t(
                    'COM_HTPROTECT_GUARD_ROLLBACK_LOGIN',
                    count($candidates),
                    '<code>' . implode('</code>, <code>', $tried) . '</code>'
                ));
            }

            // Trennleerzeichen bewusst hier im Code (nicht im Sprachstring) -
            // JED beanstandet fuehrende Leerzeichen in Uebersetzungen.
            $message = $okMessage . ' ' . htp_t('COM_HTPROTECT_GUARD_SELFTEST_OK');

            if (self::dirOf($working) !== $admin) {
                $message .= ' ' . htp_t('COM_HTPROTECT_GUARD_PATH_HINT', htp_esc(self::dirOf($working)));
            }

            // Schutz bestaetigt aktiv -> Cache-Bilder-Ausnahme setzen, ABER nur wenn der
            // Server das FilesMatch/IfModule-Konstrukt vertraegt (Selbsttest, $withException).
            // Chokt der Server darauf, bleibt sie weg -> kein 500 im Cache-Pfad.
            self::syncCacheImageException($withException);

            return array(
                'success'  => true,
                'message'  => $message,
                'warnings' => $warnings,
                'auth_dir' => self::dirOf($working),
            );
        }

        // Backend OHNE Prompt erreichbar (Auth ignoriert, z. B. AllowOverride None) UND noch
        // am /administrator-Pfad -> ungeschuetzt, aber niemand ausgesperrt: nur warnen.
        $finalUrl  = isset($probe['final_url']) ? (string) $probe['final_url'] : '';
        $leftAdmin = ($finalUrl !== '' && stripos($finalUrl, '/administrator') === false);

        if ($status === 200 && !$leftAdmin) {
            $warnings[] = htp_t('COM_HTPROTECT_GUARD_WARN_NOPROMPT', $status);

            return array('success' => true, 'message' => $okMessage, 'warnings' => $warnings, 'auth_dir' => self::dirOf($authPath));
        }

        // Nicht pruefbar (kein HTTP moeglich) -> warnen, aber nichts rueckgaengig machen.
        if ($status === 0) {
            $errSuffix  = $probe['error'] !== '' ? ': ' . htp_esc($probe['error']) : '';
            $warnings[] = htp_t('COM_HTPROTECT_GUARD_WARN_UNREACHABLE', $errSuffix);

            return array('success' => true, 'message' => $okMessage, 'warnings' => $warnings, 'auth_dir' => self::dirOf($authPath));
        }

        // Jeder ANDERE Ausgang (Redirect aufs Frontend, 404/403 statt 401-Prompt, gefolgter
        // Redirect ...) = /administrator ist nach dem Schreiben NICHT sauber als Backend
        // erreichbar -> zuruecksetzen, damit niemand ausgesperrt wird (kein 401 = kein Schutz,
        // aber auch kein Lockout). Tritt z. B. auf, wenn der Server den 401 trotz allem
        // umbiegt; dann hilft nur der Ordnerschutz des Hosting-Panels.
        self::restoreFile($htaccess, $origHtaccess);
        self::restoreFile($htpasswd, $origHtpasswd);

        return self::fail(htp_t('COM_HTPROTECT_GUARD_ROLLBACK_REDIRECT', $status));
    }

    /**
     * Moegliche AuthUserFile-Pfade in sinnvoller Reihenfolge ermitteln.
     * Apache reicht seine eigene Pfadsicht per SCRIPT_FILENAME und
     * DOCUMENT_ROOT durch - in chroot-Umgebungen weicht sie von der
     * PHP-Sicht ab.
     *
     * @param   string  $htpasswd   .htpasswd-Pfad aus PHP-Sicht
     * @param   string  $manualDir  Manuell vorgegebener administrator-Ordner ('' = keiner)
     *
     * @return array Liste vollstaendiger .htpasswd-Pfade, Duplikate entfernt
     */
    private static function candidateAuthPaths($htpasswd, $manualDir)
    {
        $dirs = array();

        if ($manualDir !== '') {
            $dirs[] = $manualDir;
        }

        // PHP-Sicht (Standard)
        $dirs[] = self::dirOf($htpasswd);

        // Apache-Sicht: SCRIPT_FILENAME zeigt fuer diese Backend-Anfrage
        // auf administrator/index.php, so wie der Webserver den Pfad sieht
        foreach (array('SCRIPT_FILENAME', 'ORIG_SCRIPT_FILENAME') as $key) {
            if (!empty($_SERVER[$key]) && is_string($_SERVER[$key])) {
                $value = str_replace('\\', '/', (string) $_SERVER[$key]);

                if (strpos($value, '://') === false && preg_match('~^(?:[a-zA-Z]:)?/~', $value)) {
                    $dirs[] = dirname($value);
                }
            }
        }

        // Apache-Sicht: DOCUMENT_ROOT (+ Unterverzeichnis der Site)
        $sub = rtrim((string) parse_url(htp_site_url(), PHP_URL_PATH), '/');

        foreach (array('CONTEXT_DOCUMENT_ROOT', 'DOCUMENT_ROOT') as $key) {
            if (!empty($_SERVER[$key]) && is_string($_SERVER[$key])) {
                $root = rtrim(str_replace('\\', '/', (string) $_SERVER[$key]), '/');

                if ($root !== '') {
                    $dirs[] = $root . $sub . '/administrator';
                }
            }
        }

        // Symlinks aufgeloest
        $real = @realpath(self::dirOf($htpasswd));

        if (is_string($real) && $real !== '') {
            $dirs[] = $real;
        }

        $paths = array();

        foreach ($dirs as $dir) {
            $dir = self::cleanDir($dir);

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

            $path = $dir . '/.htpasswd';

            if (!in_array($path, $paths, true)) {
                $paths[] = $path;
            }
        }

        return $paths;
    }

    /**
     * Verzeichnisangabe bereinigen; unbrauchbare Werte ergeben ''.
     *
     * @param   string  $dir  Rohwert
     *
     * @return string
     */
    private static function cleanDir($dir)
    {
        $dir = rtrim(str_replace('\\', '/', trim((string) $dir)), '/');

        if ($dir === '' || strpos($dir, '"') !== false || strpos($dir, "\n") !== false || strpos($dir, "\r") !== false || strpos($dir, chr(0)) !== false) {
            return '';
        }

        return $dir;
    }

    /**
     * Wirksamer Serverpfad-Override fuer setup(). MIGRATIONS-ROBUSTHEIT: Ein vorgegebener oder
     * gemerkter Pfad (meist cfg['guard_dir']), der auf DIESEM Server nicht als BESCHREIBBARES
     * VERZEICHNIS existiert, stammt typisch aus einem Backup/Umzug (alter absoluter Pfad eines
     * anderen Servers/Projekts - z. B. bei Mittwald, wo der Apache-Pfad vom PHP-Pfad abweicht und
     * guard_dir daher ueberhaupt gesetzt wird). Ein solcher toter Pfad wird VERWORFEN (Rueckgabe
     * ''), statt die .htpasswd blind dorthin zu schreiben und hart zu scheitern - ohne Override
     * findet setup() den korrekten Live-Ort ueber die Kandidatenliste + HTTP-Selbsttest neu und
     * korrigiert den gespeicherten Pfad (guard_dir) beim naechsten Erfolg von selbst. Ein
     * gueltiges, beschreibbares Verzeichnis (auch ein bewusst gesetzter Sonderpfad) bleibt
     * unangetastet.
     *
     * @param   string  $manualDir  roher Override
     *
     * @return string  wirksamer Override ('' = keiner -> Standard /administrator)
     */
    public static function effectiveManualDir($manualDir)
    {
        $manualDir = self::cleanDir($manualDir);

        if ($manualDir !== '' && (!@is_dir($manualDir) || !@is_writable($manualDir))) {
            return '';
        }

        return $manualDir;
    }

    /**
     * Verzeichnis eines Pfads mit Forward-Slashes.
     *
     * @param   string  $path  Dateipfad
     *
     * @return string
     */
    private static function dirOf($path)
    {
        return rtrim(str_replace('\\', '/', (string) dirname((string) $path)), '/');
    }

    /**
     * Schutz-Block fuer die /administrator/.htaccess erzeugen.
     *
     * @param   string   $htpasswdPath         Absoluter Pfad zur .htpasswd
     * @param   boolean  $withUpdateException  Ausnahme fuer extract.php/restore.php aufnehmen?
     *
     * @return string
     */
    private static function block($htpasswdPath, $withUpdateException)
    {
        $lines = array(
            self::MARK_BEGIN,
            'AuthType Basic',
            'AuthName "Secured"',
            'AuthUserFile "' . $htpasswdPath . '"',
            'Require valid-user',
            // Apaches eingebaute 401-Seite erzwingen: manche Server/Panels (z. B. einige
            // ISPConfig-vHosts) erben ein ErrorDocument 401, das den 401 auf eine CMS-Seite
            // umbiegt -> dann erscheint KEIN Passwort-Prompt, sondern eine 404/Frontend-Seite
            // (Backend faktisch ausgesperrt). "default" setzt das pro Verzeichnis zurueck.
            'ErrorDocument 401 default',
        );

        if ($withUpdateException) {
            $lines[] = '# Update-/Restore-Endpunkte bleiben ohne Basic-Auth erreichbar';
            $lines[] = '<FilesMatch "^(extract|restore)\.php$">';
            $lines[] = '  <IfModule !mod_authz_core.c>';
            $lines[] = '    Allow from all';
            $lines[] = '    Satisfy Any';
            $lines[] = '  </IfModule>';
            $lines[] = '  <IfModule mod_authz_core.c>';
            $lines[] = '    Require all granted';
            $lines[] = '  </IfModule>';
            $lines[] = '</FilesMatch>';
        }

        $lines[] = self::MARK_END;

        return implode("\n", $lines);
    }

    /**
     * Bestehenden Inhalt plus Schutz-Block in die .htaccess schreiben.
     *
     * @param   string  $htaccess  Zielpfad
     * @param   string  $existing  Fremder Inhalt (bereits ohne eigenen Block)
     * @param   string  $block     Schutz-Block
     *
     * @return boolean
     */
    private static function writeBlock($htaccess, $existing, $block)
    {
        $content = $existing !== ''
            ? $existing . "\n\n" . $block . "\n"
            : $block . "\n";

        if (@file_put_contents($htaccess, $content, LOCK_EX) === false) {
            return false;
        }

        @chmod($htaccess, 0644);

        return true;
    }

    // ─────────────────────────────────────────────────────────────────
    //  Option 2: Zugangs-String / Cookie-Gate (Alternative zum Passwort)
    // ─────────────────────────────────────────────────────────────────

    /**
     * Gueltiger Zugangs-String? Streng auf .htaccess-/Cookie-/URL-sichere Zeichen begrenzt
     * (kein Regex-Metazeichen, kein Encoding noetig), 5-64 Zeichen.
     *
     * @param   string  $s
     *
     * @return boolean
     */
    public static function validGateString($s)
    {
        return is_string($s) && preg_match('/^[A-Za-z0-9_-]{5,64}$/', $s) === 1;
    }

    /**
     * Cookie-Gate-Block fuer die administrator/.htaccess bauen (Option 2). Spiegelt die bewaehrte
     * Notfall-Mechanik: (1) ?<String> setzt ein langlebiges Secure+HttpOnly-Cookie und leitet OHNE
     * Query um (QSD -> der String bleibt nicht in Adresszeile/Referer). (2) Jede Anfrage ohne
     * gueltiges Zugangs-Cookie -> 404 (Backend wirkt "nicht vorhanden"). KEIN literales Leerzeichen
     * im Muster (Apache zerlegt die Zeile sonst an Spaces -> 500); daher \s* nach dem Semikolon.
     *
     * @param   string   $secret  bereits validierter Zugangs-String
     * @param   boolean  $secure  Secure-Flag ans Cookie haengen (nur bei HTTPS-Zwang)
     *
     * @return string
     */
    private static function gateBlock($secret, $secure)
    {
        $cook = self::GATE_COOKIE;
        $sec  = $secure ? 'secure' : '';

        $lines = array(
            self::MARK_GATE_BEGIN,
            '# ' . htp_tc('COM_HTPROTECT_GUARD_GATE_C1', 'Backend-Zugang nur ueber den geheimen Zugangs-Link (setzt ein Cookie).'),
            '# ' . htp_tc('COM_HTPROTECT_GUARD_GATE_C2', 'Aufruf: /administrator/?<String>. In der Komponente unter "Backend-Zugang" abschaltbar.'),
            '<IfModule mod_rewrite.c>',
            '    RewriteEngine On',
            '    RewriteCond %{QUERY_STRING} (^|&)' . $secret . '($|&)',
            '    RewriteRule ^ %{REQUEST_URI} [CO=' . $cook . ':' . $secret . ':%{HTTP_HOST}:525600:/:' . $sec . ':HttpOnly,QSD,R=302,L]',
            '    RewriteCond %{HTTP_COOKIE} !(^|;\\s*)' . $cook . '=' . $secret . '(;|$)',
            '    RewriteRule ^ - [R=404,L]',
            '</IfModule>',
            self::MARK_GATE_END,
        );

        return implode("\n", $lines);
    }

    /** Eigenen Cookie-Gate-Block aus einem .htaccess-Inhalt entfernen (getrimmt). */
    private static function stripGateBlock($content)
    {
        $begin = preg_quote(self::MARK_GATE_BEGIN, '/');
        $end   = preg_quote(self::MARK_GATE_END, '/');

        return trim((string) preg_replace('/' . $begin . '.*?' . $end . '\\s*/su', '', (string) $content));
    }

    /** Ist aktuell das Cookie-Gate (Option 2) in der WEB-Admin-.htaccess aktiv? */
    public static function gateActive()
    {
        $htaccess = htp_web_admin_root() . '/.htaccess';

        return is_file($htaccess) && strpos((string) @file_get_contents($htaccess), self::MARK_GATE_BEGIN) !== false;
    }

    /**
     * Option 2 aktivieren: Cookie-Gate mit dem Zugangs-String einrichten (Alternative zum Passwort).
     * Schreibt in die WEB-ausgelieferte administrator/.htaccess (htp_web_admin_root) - dieselbe Datei
     * wie der Passwortschutz -, entfernt vorher BEIDE eigenen Bloecke (Passwort + evtl. altes Gate),
     * Fremdinhalt bleibt. KEINE .htpasswd. LOCKOUT-SICHER: der Betreiber kennt den String immer und
     * das 1-Klick-Login traegt ihn ohnehin mit -> kein Aussperren. Der Selbsttest bestaetigt nur die
     * Wirksamkeit; nur eine durch die Regeln kaputte .htaccess (500) wird zurueckgerollt.
     *
     * @param   string  $secret  Zugangs-String (>=5, [A-Za-z0-9_-])
     *
     * @return array ['success' => bool, 'message' => string, 'warnings' => array]
     */
    public static function setupString($secret)
    {
        $secret = trim((string) $secret);

        // Fuehrendes '?' tolerieren: die UI zeigt den String konsistent als "?String" (wie das
        // Admin-URL-Feld). Der Cookie-Gate-RewriteCond braucht aber den REINEN Token (ein '?' waere
        // im Regex ein Quantifizierer) -> hier abstreifen, danach validieren/speichern.
        if ($secret !== '' && $secret[0] === '?') {
            $secret = substr($secret, 1);
        }

        if (!self::validGateString($secret)) {
            return self::fail(htp_tc('COM_HTPROTECT_GUARD_GATE_ERR_STRING', 'Ungueltiger Zugangs-String (mindestens 5 Zeichen, nur Buchstaben, Ziffern, _ und -).'));
        }

        $htaccess = htp_web_admin_root() . '/.htaccess';
        $dir      = dirname($htaccess);

        if (!@is_dir($dir)) {
            @mkdir($dir, 0755, true); // im Public-Folder-Modus existiert public/administrator; klassisch ohnehin
        }

        $orig     = is_file($htaccess) ? (string) @file_get_contents($htaccess) : null;
        $existing = self::stripGateBlock(self::stripBlock((string) $orig)); // beide eigenen Bloecke raus
        $cfg      = class_exists('HtpConfig') ? HtpConfig::load() : array();
        $secure   = !empty($cfg['force_https']);

        if (!self::writeBlock($htaccess, $existing, self::gateBlock($secret, $secure))) {
            return self::fail(htp_tc('COM_HTPROTECT_GUARD_GATE_ERR_WRITE', 'Die administrator/.htaccess ist nicht beschreibbar.'));
        }

        if (class_exists('HtpConfig')) {
            HtpConfig::saveKeys(array('guard_mode' => 'string', 'guard_string' => $secret, 'guard_dismissed' => 0));
        }

        $warnings = array();

        // Selbsttest: ohne Cookie -> 404 (wirksam); mit ?String -> 302 + Set-Cookie (Einstieg ok).
        // 500 = durch die Regeln kaputte .htaccess -> sauberer Rollback auf den Originalzustand.
        if (class_exists('HtpProbe') && HtpProbe::available()) {
            $base  = rtrim(htp_site_url(), '/') . '/administrator/';
            $plain = HtpProbe::fetch($base, false);
            $entry = HtpProbe::fetch($base . '?' . $secret, false);
            $ps    = (int) (isset($plain['status']) ? $plain['status'] : 0);
            $es    = (int) (isset($entry['status']) ? $entry['status'] : 0);

            if ($ps >= 500 || $es >= 500) {
                if ($orig === null) {
                    @unlink($htaccess);
                } else {
                    @file_put_contents($htaccess, $orig, LOCK_EX);
                }

                if (class_exists('HtpConfig')) {
                    HtpConfig::saveKeys(array('guard_mode' => '', 'guard_string' => ''));
                }

                return self::fail(htp_tc('COM_HTPROTECT_GUARD_GATE_ERR_SELFTEST', 'Die Zugangs-Regeln haben das Backend gestoert - Aenderung zurueckgenommen.'));
            }

            if ($ps !== 404 && $ps !== 0) {
                $warnings[] = htp_tc('COM_HTPROTECT_GUARD_GATE_WARN_NOEFFECT', 'Der Server wertet die .htaccess-Regeln nicht aus (mod_rewrite/AllowOverride) - der Zugangs-String greift noch nicht.');
            }
        } else {
            $warnings[] = htp_tc('COM_HTPROTECT_GUARD_WARN_NOHTTP', 'Selbsttest nicht moeglich (keine ausgehende HTTP-Verbindung).');
        }

        self::syncCacheImageException(true);

        return array('success' => true, 'message' => htp_tc('COM_HTPROTECT_GUARD_GATE_SET', 'Zugangs-String aktiviert.'), 'warnings' => $warnings);
    }

    /**
     * Ausnahme fuer STATISCHE Bilder unter administrator/cache/ an-/abschalten: manche
     * Galerie-Erweiterungen (z. B. SIGplus) legen generierte Vorschau-Bilder unter
     * administrator/cache/preview/ ab; die werden vom /administrator-Passwortschutz sonst
     * mit-geblockt (401). Eine eigene, pfad-begrenzte administrator/cache/.htaccess gibt NUR
     * Bilddateien frei - dateibasiert (FilesMatch -> kein PATH_INFO-Bypass zu einer .php),
     * kein PHP (das bleibt zusaetzlich vom PHP-Schild gesperrt), keine anderen Endungen.
     * Reiner, generierter Cache-Inhalt (Hash-Namen, nicht-sensibel). Portables 2.2/2.4-
     * Muster (IfModule mod_authz_core) - identisch zur bewaehrten extract/restore-Ausnahme.
     *
     * SICHER/NEBENWIRKUNGSFREI: nur eine per CACHE_MARK als UNSERE erkannte Datei wird
     * geschrieben/entfernt; eine FREMDE cache/.htaccess (z. B. bewusste Sperre) bleibt
     * unberuehrt. Existiert administrator/cache/ nicht, wird nichts angelegt. Fail-soft.
     * Aufrufer gaten $enable auf den Server-vertraegt-FilesMatch-Nachweis ($withException).
     *
     * @param   boolean  $enable  true = Ausnahme schreiben, false = eigene Ausnahme entfernen
     *
     * @return void
     */
    private static function syncCacheImageException($enable)
    {
        $dir  = str_replace('\\', '/', htp_web_admin_root()) . '/cache';
        $file = $dir . '/.htaccess';

        $ours = '';

        if (@is_file($file)) {
            $ours = (string) @file_get_contents($file);
        }

        // Fremde (nicht von uns markierte) cache/.htaccess NIEMALS anfassen.
        if ($ours !== '' && strpos($ours, self::CACHE_MARK) === false) {
            return;
        }

        if (!$enable) {
            if ($ours !== '') {
                @unlink($file);
            }

            return;
        }

        if (!@is_dir($dir)) {
            return; // administrator/cache legt Joomla selbst an; fehlt es -> nichts tun
        }

        $lines = array(
            self::CACHE_MARK,
            '# Statische Vorschau-/Cache-Bilder (z. B. SIGplus) trotz /administrator-Passwort-',
            '# schutz ausliefern. NUR Bilddateien - kein PHP (bleibt vom PHP-Schild gesperrt).',
            '<FilesMatch "(?i)\.(jpe?g|png|gif|webp|avif|bmp|ico)$">',
            '  <IfModule !mod_authz_core.c>',
            '    Allow from all',
            '    Satisfy Any',
            '  </IfModule>',
            '  <IfModule mod_authz_core.c>',
            '    Require all granted',
            '  </IfModule>',
            '</FilesMatch>',
        );

        if (@file_put_contents($file, implode("\n", $lines) . "\n", LOCK_EX) !== false) {
            @chmod($file, 0644);
        }
    }

    /**
     * Datei auf ihren vorherigen Zustand zuruecksetzen.
     *
     * @param   string       $path  Dateipfad
     * @param   string|null  $orig  Vorheriger Inhalt oder null (Datei existierte nicht)
     *
     * @return void
     */
    private static function restoreFile($path, $orig)
    {
        if ($orig === null) {
            @unlink($path);

            return;
        }

        @file_put_contents($path, $orig, LOCK_EX);
        @chmod($path, 0644);
    }

    /**
     * Fehler-Ergebnis bauen.
     *
     * @param   string  $message  Meldung
     *
     * @return array
     */
    private static function fail($message)
    {
        return array('success' => false, 'message' => $message, 'warnings' => array());
    }

    /**
     * Absoluter .htpasswd-Pfad aus Sicht des Webservers - nutzt den beim
     * Einrichten erkannten guard_dir, sonst die PHP-Sicht. Fuer den
     * Notfall-Modus (Builder) und das Neu-Schreiben des Blocks.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return string
     */
    public static function authUserFilePath($cfg)
    {
        // Bereits in der administrator/.htaccess referenzierte, vorhandene
        // .htpasswd hat Vorrang - so wird ein anderer/geteilter Speicherort
        // (auch ein fremd eingerichteter) korrekt weiterverwendet.
        $detected = self::detectAuthUserFile();

        if ($detected !== '' && @is_file($detected)) {
            return $detected;
        }

        // effectiveManualDir statt cleanDir: ein aus einem Umzug uebernommener, auf DIESEM Server
        // toter guard_dir wird verworfen -> Rueckfall auf den echten /administrator (Selbstheilung).
        $dir = isset($cfg['guard_dir']) ? self::effectiveManualDir($cfg['guard_dir']) : '';

        if ($dir === '') {
            $dir = str_replace('\\', '/', htp_admin_root());
        }

        return rtrim($dir, '/') . '/.htpasswd';
    }

    /**
     * Inhalt einer .htpasswd mit der Zeile dieses Nutzers bilden.
     *  - eigene Datei (administrator/.htpasswd) ODER leer/neu: genau EINE Zeile.
     *  - uebernommene/geteilte Datei: vorhandene Eintraege ANDERER Nutzer bleiben
     *    erhalten, nur die Zeile dieses Nutzers wird ersetzt bzw. ergaenzt.
     *
     * @param   string|null  $existing  Bisheriger Inhalt (null = Datei fehlte)
     * @param   string       $user      Benutzername
     * @param   string       $hash      Apache-Hash
     * @param   boolean      $isOwn     Exklusiv von HTProtect verwaltete Datei?
     *
     * @return string
     */
    private static function htpasswdWithUser($existing, $user, $hash, $isOwn)
    {
        $line = $user . ':' . $hash;

        if ($isOwn || $existing === null || trim((string) $existing) === '') {
            return $line . "\n";
        }

        $out = array();

        foreach (preg_split('/\r\n|\r|\n/', (string) $existing) as $row) {
            if (trim($row) === '') {
                continue;
            }

            $cpos = strpos($row, ':');
            $name = $cpos === false ? $row : substr($row, 0, $cpos);

            if ($name === $user) {
                continue;
            }

            $out[] = $row;
        }

        $out[] = $line;

        return implode("\n", $out) . "\n";
    }

    /**
     * Den Auth-Block in /administrator/.htaccess aus der vorhandenen
     * .htpasswd neu schreiben (z. B. nach Fremd-/Loeschung durch den
     * Waechter), ohne dass das Passwort bekannt sein muss.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return boolean
     */
    public static function rebuildBlock($cfg)
    {
        $htaccess = htp_web_admin_root() . '/.htaccess';

        // Option 2 (Cookie-Gate) aktiv? -> das Gate aus dem gemerkten Zugangs-String neu schreiben
        // (kein .htpasswd noetig). Der String liegt in der Config; validiert, sonst kein Gate.
        $gateSecret = isset($cfg['guard_string']) ? (string) $cfg['guard_string'] : '';

        if (isset($cfg['guard_mode']) && $cfg['guard_mode'] === 'string' && self::validGateString($gateSecret)) {
            $existing = is_file($htaccess) ? self::stripGateBlock(self::stripBlock((string) @file_get_contents($htaccess))) : '';
            $secure   = !empty($cfg['force_https']);
            $ok       = self::writeBlock($htaccess, $existing, self::gateBlock($gateSecret, $secure));

            if ($ok) {
                self::syncCacheImageException(true);
            }

            return $ok;
        }

        // Option 1 (Passwortschutz): Auth-Block aus der vorhandenen .htpasswd neu schreiben.
        $admin    = htp_admin_root();
        $htpasswd = $admin . '/.htpasswd';

        if (!is_file($htpasswd)) {
            return false;
        }

        $existing = is_file($htaccess) ? self::stripBlock((string) @file_get_contents($htaccess)) : '';
        $authPath = self::authUserFilePath($cfg);

        $ok = self::writeBlock($htaccess, $existing, self::block($authPath, true));

        if ($ok) {
            // Schutz (neu) aktiv -> die Cache-Bilder-Ausnahme mitziehen (idempotent).
            self::syncCacheImageException(true);
        }

        return $ok;
    }

    /**
     * Schutz entfernen.
     *
     * @return array ['success' => bool, 'message' => string]
     */
    public static function remove()
    {
        $admin    = htp_admin_root();
        $htaccess = htp_web_admin_root() . '/.htaccess';
        $htpasswd = $admin . '/.htpasswd';

        if (is_file($htaccess)) {
            $content  = (string) @file_get_contents($htaccess);
            // BEIDE eigenen Bloecke entfernen (Passwort + Cookie-Gate) - egal welche Option aktiv war.
            $stripped = self::stripGateBlock(self::stripBlock($content));

            if ($stripped === '') {
                if (!@unlink($htaccess)) {
                    return array('success' => false, 'message' => htp_t('COM_HTPROTECT_GUARD_ERR_REMOVE'));
                }
            } elseif (@file_put_contents($htaccess, $stripped . "\n", LOCK_EX) === false) {
                return array('success' => false, 'message' => htp_t('COM_HTPROTECT_GUARD_ERR_UPDATE'));
            }
        }

        // Gemerkten Modus/String leeren (Option 2). Idempotent; schadet dem Passwort-Pfad nicht.
        if (class_exists('HtpConfig')) {
            HtpConfig::saveKeys(array('guard_mode' => '', 'guard_string' => ''));
        }

        if (is_file($htpasswd)) {
            @unlink($htpasswd);
        }

        // Public-Folder-Uebergang: einen evtl. von einer AELTEREN Fassung faelschlich OBERHALB des
        // Webroots (htp_admin_root/.htaccess) hinterlassenen, nie ausgelieferten Block ebenfalls
        // raeumen, damit "Schutz entfernen" restlos wirkt. Nur im PF-Modus, nur unser eigener Block.
        if (htp_is_public_folder()) {
            $legacy = htp_admin_root() . '/.htaccess';

            if ($legacy !== $htaccess && is_file($legacy)) {
                $lc = (string) @file_get_contents($legacy);
                $ls = self::stripBlock($lc);

                if ($ls !== trim($lc)) {
                    if ($ls === '') {
                        @unlink($legacy);
                    } else {
                        @file_put_contents($legacy, $ls . "\n", LOCK_EX);
                    }
                }
            }
        }

        // Schutz weg -> die eigene Cache-Bilder-Ausnahme mit entfernen (fremde bleibt).
        self::syncCacheImageException(false);

        return array('success' => true, 'message' => htp_t('COM_HTPROTECT_GUARD_REMOVED'));
    }

    // ─────────────────────────────────────────────────────────────────
    //  Weitere Backend-Zugaenge (zusaetzliche .htpasswd-Zeilen)
    //  Verwalten NUR die EIGENE administrator/.htpasswd - nie die .htaccess,
    //  nie eine erkannte fremde/geteilte Passwortdatei. Ein leeres Ergebnis
    //  ist ausgeschlossen (sonst sperrt "Require valid-user" alle aus).
    // ─────────────────────────────────────────────────────────────────

    /**
     * Absoluter Pfad der EIGENEN Passwortdatei (administrator/.htpasswd) - die von
     * HTProtect selbst gefuehrte Datei. Add/Remove weiterer Zugaenge schreiben
     * ausschliesslich hierher, NIE in eine erkannte fremde/geteilte .htpasswd.
     *
     * @return string
     */
    private static function ownFile()
    {
        return str_replace('\\', '/', htp_admin_root()) . '/.htpasswd';
    }

    /**
     * Fuehrt HTProtect die Passwortdatei selbst (eigene administrator/.htpasswd) UND
     * ist der Schutz aktiv? Nur dann duerfen weitere Zugaenge verwaltet werden - das
     * einzige Feature-/UI-Gate. Spiegelt die $adopt-Logik aus setup() (oben) byte-genau:
     * eine als FREMD erkannte, real vorhandene .htpasswd in einem ANDEREN Ordner -> nicht
     * eigen. Chroot-sicher: eine reine Apache-Sicht-Pfadangabe ist aus PHP nicht is_file()
     * -> nicht als fremd gewertet (PHP liest/schreibt real immer administrator/.htpasswd).
     *
     * @return boolean
     */
    public static function isOwnActive()
    {
        $st = self::status();

        if (empty($st['active'])) {
            return false;
        }

        $admin    = str_replace('\\', '/', htp_admin_root());
        $detected = self::detectAuthUserFile();
        $shared   = ($detected !== '' && @is_file($detected) && self::dirOf($detected) !== $admin);

        return !$shared && is_file($admin . '/.htpasswd');
    }

    /**
     * Benutzernamen aus einem .htpasswd-Inhalt lesen (rein, testbar). Zaehlt NUR
     * wohlgeformte "name:hash"-Zeilen; Leerzeilen und Zeilen ohne ":" werden
     * ignoriert (bewusst strenger als der Merge-Loop, damit handverfaelschte
     * Kommentar-/Garbage-Zeilen nicht als gueltiger Login zaehlen).
     *
     * @param   string  $content  Dateiinhalt
     *
     * @return array Benutzernamen in Dateireihenfolge
     */
    public static function parseUsers($content)
    {
        $out = array();

        foreach (preg_split('/\r\n|\r|\n/', (string) $content) as $row) {
            if (trim($row) === '') {
                continue;
            }

            $cpos = strpos($row, ':');

            if ($cpos === false) {
                continue;
            }

            $out[] = substr($row, 0, $cpos);
        }

        return $out;
    }

    /**
     * Benutzernamen der EIGENEN Passwortdatei.
     *
     * @return array
     */
    public static function listUsers()
    {
        return self::parseUsers((string) @file_get_contents(self::ownFile()));
    }

    /**
     * Weiteren Backend-Zugang (Browser-Login fuer die /administrator-Passwortabfrage)
     * anlegen. Aendert NUR administrator/.htpasswd - nie die .htaccess -> kann niemanden
     * aussperren. Ein bereits vorhandener gleicher Name gilt als Passwortaenderung. Falls
     * ein HTTP-Selbsttest moeglich ist, wird mit den NEUEN Zugangsdaten angemeldet; weist
     * der Server sie ab (Hash-Format fuer dieses Apache unlesbar), wird die neue Zeile
     * zurueckgerollt.
     *
     * @param   string  $username  Benutzername
     * @param   string  $password  Passwort (roh)
     *
     * @return array ['success' => bool, 'message' => string, 'warnings' => array]
     */
    public static function addUser($username, $password)
    {
        if (!self::isOwnActive()) {
            return self::fail(htp_t('COM_HTPROTECT_GUARD_USERS_ERR_NOTOWN'));
        }

        $username = trim((string) $username);
        $password = (string) $password;

        if ($username === '') {
            return self::fail(htp_t('COM_HTPROTECT_GUARD_ERR_EMPTY'));
        }

        if (!preg_match('/^[a-zA-Z0-9._@\-]+$/', $username)) {
            return self::fail(htp_t('COM_HTPROTECT_GUARD_ERR_USERCHARS'));
        }

        if (strlen($password) < 8) {
            return self::fail(htp_t('COM_HTPROTECT_GUARD_ERR_PWLEN'));
        }

        $hash = self::hash($password);

        if ($hash === false) {
            return self::fail(htp_t('COM_HTPROTECT_GUARD_ERR_NOHASH'));
        }

        $file    = self::ownFile();
        $orig    = is_file($file) ? (string) @file_get_contents($file) : null;
        $existed = in_array($username, self::parseUsers((string) $orig), true);

        // isOwn=false -> Merge-Branch behaelt alle anderen Zeilen, ersetzt/ergaenzt nur diese.
        $body = self::htpasswdWithUser($orig, $username, $hash, false);

        if (@file_put_contents($file, $body, LOCK_EX) === false) {
            self::restoreFile($file, $orig);

            return self::fail(htp_t('COM_HTPROTECT_GUARD_ERR_HTPASSWD_WRITE'));
        }

        @chmod($file, 0644);

        // Selbsttest: mit den NEUEN Zugangsdaten anmelden. Nur ein DIREKTES Ablehnen
        // (401/403 = Zeile fuer dieses Apache unbrauchbar) rollt zurueck; unerreichbar/
        // 500 sind nicht durch das Anfuegen einer Zeile verursacht -> Add bleibt stehen.
        // Die .htaccess bleibt unberuehrt -> ein Rollback kann niemanden aussperren.
        if (class_exists('HtpProbe') && HtpProbe::available()) {
            $login  = HtpProbe::fetch(htp_site_url() . 'administrator/index.php', false, array($username, $password));
            $status = (int) $login['status'];

            if ($status === 401 || $status === 403) {
                self::restoreFile($file, $orig);

                return self::fail(htp_t('COM_HTPROTECT_GUARD_USERS_ERR_SELFTEST'));
            }
        }

        $msg = $existed
            ? htp_t('COM_HTPROTECT_GUARD_USERS_UPDATED', htp_esc($username))
            : htp_t('COM_HTPROTECT_GUARD_USERS_ADDED', htp_esc($username));

        return array('success' => true, 'message' => $msg, 'warnings' => array());
    }

    /**
     * Einen Backend-Zugang entfernen. Aendert NUR administrator/.htpasswd. Der LETZTE
     * verbleibende Zugang laesst sich hier NICHT entfernen - eine leere .htpasswd wuerde
     * mit "Require valid-user" alle aussperren (403). Zum kompletten Abschalten dient
     * "Schutz entfernen". Liest die Datei EINMAL und baut das Ergebnis aus DERSELBEN
     * Momentaufnahme (schliesst paralleles Leeren aus) + Read-back-Absicherung.
     *
     * @param   string  $username  Zu entfernender Benutzername
     *
     * @return array ['success' => bool, 'message' => string, 'warnings' => array]
     */
    public static function removeUser($username)
    {
        if (!self::isOwnActive()) {
            return self::fail(htp_t('COM_HTPROTECT_GUARD_USERS_ERR_NOTOWN'));
        }

        $username = trim((string) $username);
        $file     = self::ownFile();
        $orig     = (string) @file_get_contents($file);

        if (!in_array($username, self::parseUsers($orig), true)) {
            return self::fail(htp_t('COM_HTPROTECT_GUARD_USERS_ERR_NOTFOUND'));
        }

        // Body aus DERSELBEN Momentaufnahme: alle nicht-leeren Zeilen behalten, deren
        // Name != $username. Rohe Zeilen unveraendert uebernehmen.
        $out = array();

        foreach (preg_split('/\r\n|\r|\n/', $orig) as $row) {
            if (trim($row) === '') {
                continue;
            }

            $cpos = strpos($row, ':');
            $name = $cpos === false ? $row : substr($row, 0, $cpos);

            if ($name === $username) {
                continue;
            }

            $out[] = $row;
        }

        $body = $out ? implode("\n", $out) . "\n" : '';

        // Autoritative Leerdatei-Sperre: das ERGEBNIS darf keinen gueltigen Login verlieren.
        // Fangt in EINEM Ausdruck sowohl den Letzter-Nutzer- als auch den Doppel-/Garbage-Fall.
        if (count(self::parseUsers($body)) < 1) {
            return self::fail(htp_t('COM_HTPROTECT_GUARD_USERS_ERR_LAST'));
        }

        if (@file_put_contents($file, $body, LOCK_EX) === false) {
            self::restoreFile($file, $orig);

            return self::fail(htp_t('COM_HTPROTECT_GUARD_ERR_HTPASSWD_WRITE'));
        }

        // Read-back gegen Race/Teil-Schreibung: bleibt kein Login uebrig, sofort zurueck.
        if (count(self::parseUsers((string) @file_get_contents($file))) < 1) {
            self::restoreFile($file, $orig);

            return self::fail(htp_t('COM_HTPROTECT_GUARD_USERS_ERR_LAST'));
        }

        @chmod($file, 0644);

        return array(
            'success'  => true,
            'message'  => htp_t('COM_HTPROTECT_GUARD_USERS_REMOVED', htp_esc($username)),
            'warnings' => array(),
        );
    }

    /**
     * Zufaelliges Wiederherstellungs-Token (Roh, 64 Hex = 256 Bit). Kommt EINMALIG in die
     * Einrichtungs-Mail; gespeichert (guard_recover) wird nur sein SHA-256-HASH, NIE das
     * Token selbst. Fail-soft: '' wenn keine starke Zufallsquelle da ist (dann kein Link).
     *
     * @return string  Roh-Token (Hex) oder ''
     */
    public static function makeRecoverToken()
    {
        try {
            if (function_exists('random_bytes')) {
                return bin2hex(random_bytes(32));
            }

            if (function_exists('openssl_random_pseudo_bytes')) {
                $strong = false;
                $bytes  = openssl_random_pseudo_bytes(32, $strong);

                if ($strong && is_string($bytes) && $bytes !== '') {
                    return bin2hex($bytes);
                }
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return '';
    }

    /**
     * SHA-256-Hash eines Roh-Tokens (rein). '' bei leerem Input.
     *
     * @param   string  $raw
     *
     * @return string
     */
    public static function hashRecoverToken($raw)
    {
        $raw = (string) $raw;

        return $raw === '' ? '' : hash('sha256', $raw);
    }

    /**
     * REINE, timing-sichere Token-Pruefung (testbar): passt das Roh-Token zum gespeicherten
     * SHA-256-Hash? Leeres/kein plausibles Hex-Token oder leerer Hash -> false (kein Recovery-
     * Link aktiv). hash_equals verhindert Timing-Seitenkanaele.
     *
     * @param   string  $raw         Roh-Token aus dem Link (64 Hex erwartet)
     * @param   string  $storedHash  guard_recover (SHA-256-Hash) oder ''
     *
     * @return boolean
     */
    public static function recoverTokenValid($raw, $storedHash)
    {
        $raw        = (string) $raw;
        $storedHash = (string) $storedHash;

        if ($raw === '' || $storedHash === '' || !preg_match('~^[a-f0-9]{64}$~i', $raw)) {
            return false;
        }

        return hash_equals($storedHash, self::hashRecoverToken($raw));
    }

    /**
     * Wiederherstellungs-Link (rein): FRONTEND-URL (bewusst NICHT /administrator - das ist ja
     * gesperrt), die den Backend-Schutz nach Super-User-Login auf der Website entfernen laesst.
     * '' wenn Basis oder Token fehlt.
     *
     * @param   string  $base   Vertrauenswuerdige Site-Basis (htp_trusted_base)
     * @param   string  $token  Roh-Token
     *
     * @return string
     */
    public static function recoverLink($base, $token)
    {
        $base  = rtrim((string) $base, '/');
        $token = (string) $token;

        return ($base === '' || $token === '') ? '' : $base . '/index.php?htp_recover=' . $token;
    }

    /**
     * Apache-kompatiblen Hash erzeugen:
     * 1) APR1-MD5 via crypt() - ueberall unterstuetzt
     * 2) Bcrypt via password_hash() - Apache 2.4+
     *
     * @param   string  $password  Passwort
     *
     * @return string|false
     */
    public static function hash($password)
    {
        $salt = self::salt(8);
        $apr1 = @crypt($password, '$apr1$' . $salt . '$');

        if (is_string($apr1) && strlen($apr1) > 13 && strpos($apr1, '$apr1$') === 0) {
            return $apr1;
        }

        if (function_exists('password_hash')) {
            $bcrypt = @password_hash($password, PASSWORD_BCRYPT);

            if (is_string($bcrypt) && strpos($bcrypt, '$2y$') === 0) {
                return $bcrypt;
            }
        }

        return false;
    }

    /**
     * Zufalls-Salt (kryptographisch, mit Fallback).
     *
     * @param   integer  $length  Laenge
     *
     * @return string
     */
    private static function salt($length)
    {
        $alphabet = './abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
        $salt     = '';

        if (function_exists('random_int')) {
            for ($i = 0; $i < $length; $i++) {
                $salt .= $alphabet[random_int(0, strlen($alphabet) - 1)];
            }

            return $salt;
        }

        for ($i = 0; $i < $length; $i++) {
            $salt .= $alphabet[mt_rand(0, strlen($alphabet) - 1)];
        }

        return $salt;
    }

    /**
     * Eigenen Block aus einer .htaccess entfernen.
     *
     * @param   string  $content  Dateiinhalt
     *
     * @return string Bereinigter Inhalt (getrimmt)
     */
    private static function stripBlock($content)
    {
        $begin = preg_quote(self::MARK_BEGIN, '/');
        $end   = preg_quote(self::MARK_END, '/');

        $content = (string) preg_replace(
            '/' . $begin . '.*?' . $end . '\s*/su',
            '',
            (string) $content
        );

        return trim($content);
    }
}
