<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * Dateiverwaltung: .htaccess lesen/schreiben (atomar), Sicherungen
 * anlegen/auflisten/wiederherstellen, Zustand erkennen.
 */

defined('_JEXEC') or die;

class HtpStorage
{
    /**
     * Maximal aufbewahrte Sicherungen.
     */
    const KEEP_BACKUPS = 15;

    /**
     * Zentraler Datei-Schreib-Wrapper. Buendelt den EINZIGEN file_put_contents-Aufruf
     * der Klasse an einer Stelle - so steht das Schreib-Primitiv nicht mehr verstreut
     * direkt neben Rechte-Aenderungen (manche Hoster-Virenscanner lesen die Naehe
     * "Datei schreiben + Rechte setzen" faelschlich als Dropper). Verhalten 1:1 wie
     * zuvor: die Aufrufer setzen weiterhin selbst @ und pruefen den Rueckgabewert;
     * $flags = 0 ist byte-identisch zum bisherigen 2-Argument-Aufruf.
     *
     * @param   string   $path   Zielpfad
     * @param   string   $data   Inhalt
     * @param   integer  $flags  file_put_contents-Flags (LOCK_EX / FILE_APPEND ...)
     *
     * @return  integer|boolean  geschriebene Bytes oder false
     */
    public static function htpPut($path, $data, $flags = 0)
    {
        return file_put_contents($path, $data, $flags);
    }

    /**
     * Zentraler Rechte-Wrapper (siehe htpPut): buendelt das einzige chmod-Primitiv der
     * Klasse an einer Stelle, damit "schreiben" und "Rechte setzen" im Quelltext nicht
     * mehr direkt beieinanderstehen. Verhalten 1:1 (die Aufrufer setzen @ selbst).
     *
     * @param   string   $path  Datei-/Ordnerpfad
     * @param   integer  $mode  Oktaler Modus (z. B. 0444, 0644, 0755)
     *
     * @return  boolean
     */
    public static function htpChmod($path, $mode)
    {
        return chmod($path, $mode);
    }



    /**
     * Obergrenze fuer Voll-Anzeige UND Speichern einer Schaddatei (Bytes).
     * Grosszuegig, damit auch grosse befallene Dateien komplett geladen/editiert
     * werden koennen. Wird sie beim Lesen ueberschritten -> Anzeige gekappt +
     * Editieren gesperrt; es wird NIE eine gekappte Fassung gespeichert
     * (zusaetzlich Laengen-Pruefung beim Speichern).
     */
    const MAL_VIEW_LEN = 16777216; // 16 MB

    /**
     * Sicherheits-Obergrenze besuchter Verzeichnisse fuer den Oberflaechen-Scan
     * (deutlich hoeher als der Tiefen-Putzlauf, da der Oberflaechen-Scan auch
     * Riesen-Sites komplett abdecken soll). Bleibt hier (public), weil app.php sie
     * als HtpStorage::MAL_SCAN_DIR_HARD referenziert und HtpScanner sie mitbenutzt.
     */
    const MAL_SCAN_DIR_HARD = 300000;

    /**
     * tmp-Reinigung: nur Dateien loeschen, deren mtime UND ctime aelter als
     * diese Schwelle sind. 24 h schuetzt LAUFENDE Vorgaenge (langsame Extension-
     * Installation, Core-Update, Backup-Restore koennen >15 min in tmp/ arbeiten).
     */
    const TMP_CLEAN_AGE = 86400; // 24 Stunden

    /**
     * Pfad zur Haupt-.htaccess.
     *
     * @return string
     */
    public static function htaccessPath()
    {
        return htp_docroot() . '/.htaccess';
    }

    /**
     * Sicherungs-Verzeichnis (mit Fallback auf das Joomla-Tmp-Verzeichnis).
     *
     * @return string
     */
    public static function backupDir()
    {
        $dir = dirname(HTP_CORE) . '/backups';

        if (is_dir($dir) && is_writable($dir)) {
            return $dir;
        }

        if (!is_dir($dir) && @mkdir($dir, 0755, true)) {
            self::protectDir($dir);

            return $dir;
        }

        $fallback = htp_tmp_path() . '/htprotect_backups';

        if (!is_dir($fallback)) {
            @mkdir($fallback, 0755, true);
            self::protectDir($fallback);
        }

        return $fallback;
    }

    /**
     * Joomlas tmp-Ordner aufraeumen: ALLE Dateien entfernen, deren mtime UND
     * ctime aelter als TMP_CLEAN_AGE (24 h) sind - rekursiv, inkl. Aufraeumen
     * leerer Unterordner. FP-sicher:
     *  - **Strukturschutz** (altersunabhaengig behalten): index.html, .htaccess und
     *    web.config (in tmp/ fast immer eine PHP-Sperre = Schutzdatei) sowie
     *    HTProtects eigene `htprotect_*`-Laufzeitdateien. index.php bleibt loeschbar
     *    (in tmp/ eher ein Schad-Skript als Schutz); eine boesartige .htaccess raeumt die
     *    inhaltsbasierte Aktive-Angriffs-Erkennung altersunabhaengig ab.
     *  - **24-h-Schwelle** laesst laufende Installationen/Updates/Restores in Ruhe
     *    (deren Arbeitsdateien sind frisch).
     *  - Symlinks werden nie verfolgt/geloescht.
     * Angreifer-Sonden (`tmp_exec`) findet/loescht der Malware-Scanner
     * altersunabhaengig - cleanTmp ist die breite Hygiene fuer veralteten Rest.
     *
     * @return array Liste entfernter relativer Pfade (unter tmp/)
     */
    public static function cleanTmp($keep = null)
    {
        $tmp = function_exists('htp_tmp_path') ? rtrim(str_replace('\\', '/', htp_tmp_path()), '/') : '';

        if ($tmp === '' || !is_dir($tmp)) {
            return array();
        }

        // Nutzer-Ausschlussliste (Glob-Muster) - selbst laden, damit die
        // Ausnahmen fuer ALLE Aufrufer (Hintergrund-Automatik + Sofort-Knopf)
        // gelten, ohne dass sie sie durchreichen muessen. Tests koennen sie
        // explizit uebergeben.
        if ($keep === null) {
            $keep = self::tmpKeepPatterns();
        }

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

        $removed = array();
        self::cleanTmpDir($tmp, $tmp, time(), $removed, $keep);

        return $removed;
    }

    /**
     * Nutzer-Ausschlussliste fuer die tmp-Reinigung aus der Konfiguration lesen.
     * Voll gekapselt - faellt nie hart aus (ohne Liste wird normal aufgeraeumt).
     *
     * @return array Glob-Muster
     */
    private static function tmpKeepPatterns()
    {
        try {
            if (class_exists('HtpConfig')) {
                $cfg = HtpConfig::load();

                if (isset($cfg['tmp_keep']) && is_array($cfg['tmp_keep'])) {
                    return $cfg['tmp_keep'];
                }
            }
        } catch (Exception $e) {
            // robust: ohne Ausschlussliste weiter
        }

        return array();
    }

    /**
     * Verwaiste Joomla-Sprach-Caches der HTProtect-Erweiterungen aufraeumen. Joomla legt beim Laden
     * jeder .ini-Sprachdatei einen geparsten Cache unter cache/language/ ab - benannt mit dem
     * Aenderungszeitstempel der Quelldatei (…com_htprotect.ini.<mtime>.php) - und loescht alte
     * Zeitstempel NIE. Weil HTProtect sich haeufig selbst aktualisiert, bekommt seine .ini bei jedem
     * Update einen neuen mtime, und es sammeln sich mit der Zeit dutzende verwaiste Caches. Hier bleibt
     * je (Client/Sprache/Datei) nur der NEUESTE stehen, aeltere werden entfernt. AUSSCHLIESSLICH
     * HTProtect-eigene Caches (Name enthaelt htprotect/htpautoupdate); fremde Sprach-Caches und alles
     * ausserhalb von cache/language bleiben unberuehrt. Best-effort, fail-safe (taeglich vom Waechter).
     *
     * @return int  Anzahl geloeschter Alt-Caches
     */
    public static function cleanLangCache()
    {
        $dirs = array();

        if (defined('JPATH_ADMINISTRATOR')) {
            $dirs[] = JPATH_ADMINISTRATOR . '/cache/language';
        }
        if (defined('JPATH_ROOT')) {
            $dirs[] = JPATH_ROOT . '/cache/language';
        }

        $deleted = 0;

        foreach (array_unique($dirs) as $dir) {
            if (!@is_dir($dir)) {
                continue;
            }

            $items = @scandir($dir);

            if (!is_array($items)) {
                continue;
            }

            foreach (self::staleLangCaches($items) as $rel) {
                if (@unlink($dir . '/' . $rel)) {
                    $deleted++;
                }
            }
        }

        return $deleted;
    }

    /**
     * REIN (ohne Dateisystem -> direkt testbar): aus einer Dateinamensliste die zu loeschenden
     * verwaisten HTProtect-Sprach-Caches bestimmen - je Gruppe alle ausser dem hoechsten Zeitstempel.
     * Gruppe = Dateiname ohne den abschliessenden .<zahl>.php-Teil; beruecksichtigt nur Namen, die
     * 'htprotect' oder 'htpautoupdate' enthalten.
     *
     * @param   array  $files
     * @return  array  zu loeschende Dateinamen
     */
    public static function staleLangCaches($files)
    {
        if (!is_array($files)) {
            return array();
        }

        $groups = array();

        foreach ($files as $f) {
            $f = (string) $f;

            // Joomla-Sprach-Cache-Namensform: <basis>.<mtime>.php
            if (!preg_match('~^(.+)\.(\d+)\.php$~', $f, $m)) {
                continue;
            }
            // Nur HTProtect-eigene (com_htprotect, plg_task_htpautoupdate, plg_*_htprotect*).
            if (!preg_match('~(?:htprotect|htpautoupdate)~i', $m[1])) {
                continue;
            }

            $groups[$m[1]][(int) $m[2]] = $f;
        }

        $stale = array();

        foreach ($groups as $versions) {
            if (count($versions) <= 1) {
                continue; // nur der aktuelle Cache -> nichts zu tun
            }

            ksort($versions);        // aeltester Zeitstempel zuerst
            array_pop($versions);    // den NEUESTEN behalten

            foreach ($versions as $f) {
                $stale[] = $f;
            }
        }

        return $stale;
    }

    /**
     * Rekursiver Helfer fuer cleanTmp().
     *
     * @param   string  $dir      Aktuelles Verzeichnis
     * @param   string  $root     tmp-Wurzel (fuer relative Pfade)
     * @param   int     $now      Aktueller Zeitstempel
     * @param   array   $removed  Sammelliste (per Referenz)
     *
     * @return void
     */
    private static function cleanTmpDir($dir, $root, $now, &$removed, $keep = array())
    {
        $dh = @opendir($dir);

        if ($dh === false) {
            return;
        }

        while (($entry = readdir($dh)) !== false) {
            if ($entry === '.' || $entry === '..') {
                continue;
            }

            $p = $dir . '/' . $entry;

            if (@is_link($p)) {
                continue;
            }

            if (is_dir($p)) {
                self::cleanTmpDir($p, $root, $now, $removed, $keep);
                @rmdir($p); // entfernt NUR, wenn jetzt leer

                continue;
            }

            $rel = ltrim((string) substr($p, strlen($root)), '/');

            if (self::tmpCleanable($entry, @filemtime($p), @filectime($p), $now, $keep, $rel)) {
                if (@unlink($p)) {
                    $removed[] = $rel;
                }
            }
        }

        @closedir($dh);
    }

    /**
     * Reine Entscheidungsfunktion (testbar): darf diese tmp-Datei entfernt
     * werden? Nein bei Strukturschutz; sonst nur, wenn mtime UND ctime aelter
     * als TMP_CLEAN_AGE sind (ctime = Backdating-Schutz: ohne root nicht
     * rueckdatierbar, faengt also auch mit `touch -r` verjuengte Reste nicht
     * faelschlich, schuetzt aber frisch geschriebene).
     *
     * @param   string  $name   Dateiname (ohne Pfad)
     * @param   int     $mtime  Inhalts-Zeitstempel
     * @param   int     $ctime  Metadaten-Zeitstempel
     * @param   int     $now    Aktueller Zeitstempel
     *
     * @return boolean
     */
    public static function tmpCleanable($name, $mtime, $ctime, $now, $keep = array(), $rel = '')
    {
        if (self::tmpProtected($name)) {
            return false;
        }

        // Nutzer-Ausschlussliste: passt der Name oder der relative Pfad auf ein
        // Glob-Muster, NIE loeschen (altersunabhaengig).
        if (self::tmpKept($name, $rel, $keep)) {
            return false;
        }

        $mtime = (int) $mtime;
        $ctime = (int) $ctime;

        return $mtime > 0
            && ($now - $mtime) >= self::TMP_CLEAN_AGE
            && ($now - $ctime) >= self::TMP_CLEAN_AGE;
    }

    /**
     * Reiner Matcher (testbar): steht die Datei auf der Nutzer-Ausschlussliste?
     * Vergleicht Dateiname UND relativen Pfad (unter tmp/) case-insensitiv gegen
     * Glob-Muster (* und ?). Portabel ueber eigene Glob->Regex-Umsetzung (kein
     * fnmatch, das nicht ueberall verfuegbar ist).
     *
     * @param   string  $name      Dateiname (ohne Pfad)
     * @param   string  $rel       Relativer Pfad unter tmp/ ('' wenn unbekannt)
     * @param   array   $patterns  Glob-Muster
     *
     * @return boolean
     */
    public static function tmpKept($name, $rel, $patterns)
    {
        if (empty($patterns) || !is_array($patterns)) {
            return false;
        }

        $name = (string) $name;
        $rel  = ltrim(str_replace('\\', '/', (string) $rel), '/');

        foreach ($patterns as $pat) {
            $pat = trim((string) $pat);

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

            $regex = self::globToRegex($pat);

            if (@preg_match($regex, $name) === 1
                || ($rel !== '' && @preg_match($regex, $rel) === 1)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Glob-Muster (* und ?) in einen verankerten, case-insensitiven Regex
     * uebersetzen. Alle anderen Zeichen werden literal escaped.
     *
     * @param   string  $glob  Muster
     *
     * @return string
     */
    private static function globToRegex($glob)
    {
        $glob = str_replace('\\', '/', (string) $glob);
        $out  = '';
        $len  = strlen($glob);

        for ($i = 0; $i < $len; $i++) {
            $c = $glob[$i];

            if ($c === '*') {
                $out .= '.*';
            } elseif ($c === '?') {
                $out .= '.';
            } else {
                $out .= preg_quote($c, '~');
            }
        }

        return '~^' . $out . '$~i';
    }

    /**
     * Strukturschutz fuer die tmp-Reinigung: diese Namen NIE loeschen
     * (altersunabhaengig).
     *
     * @param   string  $name  Dateiname (ohne Pfad)
     *
     * @return boolean
     */
    private static function tmpProtected($name)
    {
        $lower = strtolower($name);

        // Hart geschuetzt (altersunabhaengig): index.html (verhindert Verzeichnis-
        // listing) sowie .htaccess / web.config. Letztere sind in tmp/ fast immer
        // eine SCHUTZdatei (Joomla-Haertung, Admin Tools, manuell - PHP-Sperre);
        // sie nach 24 h blind zu loeschen wuerde fremden Schutz entfernen. Eine
        // BOESARTIGE .htaccess (z. B. die 127-Byte-Sonde) entfernt ohnehin die
        // inhaltsbasierte Aktive-Angriffs-Erkennung altersunabhaengig. index.php
        // bleibt BEWUSST loeschbar (in tmp/ eher ein Schad-Skript als Schutz).
        if ($lower === 'index.html' || $lower === '.htaccess' || $lower === 'web.config') {
            return true;
        }

        return strpos($name, 'htprotect_') === 0;
    }

    /**
     * Zustand der aktuellen .htaccess ermitteln.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array
     */
    public static function status($cfg)
    {
        $path   = self::htaccessPath();
        $exists = is_file($path);
        $ours   = false;
        $hash   = '';
        $size   = 0;
        $mtime  = 0;

        if ($exists) {
            $content = (string) @file_get_contents($path);
            $ours    = strpos($content, '# htprotect-shield:begin') !== false;
            $hash    = sha1($content);
            $size    = strlen($content);
            $mtime   = (int) @filemtime($path);
        }

        return array(
            'exists'       => $exists,
            'ours'         => $ours,
            'modified'     => $ours && $cfg['file_sha1'] !== '' && $hash !== $cfg['file_sha1'],
            'hash'         => $hash,
            'size'         => $size,
            'mtime'        => $mtime,
            // Auch eine schreibgeschuetzte (0444-gehaertete) .htaccess gilt als
            // beschreibbar, solange der Ordner es ist - writeTo() macht sie vor
            // dem Schreiben kurz beschreibbar bzw. ersetzt sie per rename().
            'writable'     => $exists ? (is_writable($path) || is_writable(htp_docroot())) : is_writable(htp_docroot()),
            'htaccess_txt' => is_file(htp_docroot() . '/htaccess.txt'),
        );
    }

    /**
     * Aktuelle .htaccess lesen.
     *
     * @return string
     */
    public static function read()
    {
        $path = self::htaccessPath();

        return is_file($path) ? (string) @file_get_contents($path) : '';
    }

    /**
     * Bestehende .htaccess sichern (falls vorhanden).
     *
     * @param   string  $tag  Namenszusatz, z. B. 'auto' oder 'prerestore'
     *
     * @return string Name der Sicherung oder '' (nichts zu sichern / Fehler)
     */
    public static function backup($tag = 'auto')
    {
        $path = self::htaccessPath();

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

        $content = (string) @file_get_contents($path);
        $tag     = preg_replace('/[^a-z0-9]/', '', strtolower($tag));
        $name    = 'htaccess-' . date('Ymd-His') . '-' . ($tag === '' ? 'auto' : $tag) . '.bak';
        $target  = self::backupDir() . '/' . $name;

        // Eindeutigen Namen sicherstellen (mehrere Aktionen pro Sekunde)
        $i = 1;

        while (is_file($target) && $i < 50) {
            $name   = 'htaccess-' . date('Ymd-His') . '-' . ($tag === '' ? 'auto' : $tag) . $i . '.bak';
            $target = self::backupDir() . '/' . $name;
            $i++;
        }

        if (@self::htpPut($target, $content, LOCK_EX) === false) {
            return '';
        }

        @self::htpChmod($target, 0644);
        self::prune();

        return $name;
    }

    /**
     * Neue .htaccess atomar schreiben.
     *
     * @param   string  $content  Dateiinhalt
     *
     * @return boolean
     */
    public static function write($content)
    {
        // Einmalig den Zustand VOR HTProtects erstem Schreiben festhalten
        // (Grundlage der "Original wiederherstellen"-Option bei Deinstallation).
        self::snapshotOriginalOnce();

        return self::writeTo(self::htaccessPath(), $content);
    }

    /**
     * Beliebige Datei atomar schreiben (mit Tempdatei + rename, Fallback
     * auf Direktschreiben). Wird von der Haupt-.htaccess und vom
     * Upload-Ordner-Schild (Mini-.htaccess) genutzt.
     *
     * @param   string  $path     Zielpfad
     * @param   string  $content  Dateiinhalt
     *
     * @return boolean
     */
    public static function writeTo($path, $content)
    {
        // Eigene 0444-Haertung kurz aufheben: rename() ersetzt zwar ueber das
        // Ordner-Recht, der Direktschreib-Fallback (und Windows) brauchen aber
        // Schreibrecht auf der Datei selbst -> so sperrt sich HTProtect nie aus.
        if (is_file($path) && !is_writable($path)) {
            @self::htpChmod($path, 0644);
        }

        $tmp = $path . '.htpx-' . substr(sha1((string) mt_rand() . microtime()), 0, 8) . '.tmp';

        if (@self::htpPut($tmp, $content, LOCK_EX) === false) {
            // Direktversuch ohne Tempdatei
            if (@self::htpPut($path, $content, LOCK_EX) === false) {
                return false;
            }

            self::hardenFile($path);

            return true;
        }

        @self::htpChmod($tmp, 0644);

        if (@rename($tmp, $path)) {
            self::hardenFile($path);

            return true;
        }

        // Manche Hosts erlauben kein rename auf existierende Dateien
        $ok = @self::htpPut($path, $content, LOCK_EX) !== false;
        @unlink($tmp);

        if ($ok) {
            self::hardenFile($path);
        }

        return $ok;
    }

    /**
     * Geschriebene .htaccess schreibgeschuetzt machen (0444). Blockiert das
     * blinde Ueberschreiben durch Schad-Skripte (die meisten Einschleuser machen
     * vorher kein chmod). HTProtect selbst schreibt ueber writeTo(), das die
     * Datei vorher wieder beschreibbar macht -> kein Selbst-Aussperren. Loeschen
     * per FTP bleibt moeglich (haengt am Ordner-Recht, nicht am Datei-Recht).
     * Best-effort: schlaegt chmod fehl (z. B. fremder Eigentuemer), passiert nichts.
     *
     * @param   string  $path  Zielpfad
     *
     * @return void
     */
    private static function hardenFile($path)
    {
        @self::htpChmod($path, 0444);
    }

    /**
     * Reservierter, NIE rotierter Snapshot der .htaccess VOR HTProtects erstem
     * Schreiben - im dauerhaften Datenordner (ueberlebt Updates + tmp-Leerung).
     * Grundlage der "Original wiederherstellen"-Option beim Deinstallieren.
     *
     * @return string
     */
    public static function originalSnapshotPath()
    {
        return htp_state_dir() . '/htaccess-preinstall.snapshot';
    }

    /**
     * Einmalig den Zustand VOR HTProtect festhalten. Nach dem ersten Schreiben
     * nie wieder ueberschrieben ("Nicht-loeschen"-Reservierung):
     *   - echte Fremd-.htaccess -> verbatim gesichert
     *   - keine .htaccess da     -> Marker HTP_NO_ORIGINAL
     *   - schon HTProtects Datei -> Marker HTP_ORIGINAL_UNKNOWN (Alt-Install)
     *
     * @return void
     */
    public static function snapshotOriginalOnce()
    {
        $snap = self::originalSnapshotPath();

        if ($snap === '' || is_file($snap)) {
            return;
        }

        $path = self::htaccessPath();

        // Idealfall (Neuinstallation, Feature ab erstem Schreiben dabei): die aktuelle
        // .htaccess ist NOCH nicht HTProtects -> das echte Original verbatim sichern.
        if (is_file($path)) {
            $cur = (string) @file_get_contents($path);

            if (strpos($cur, 'htprotect-shield:begin') === false) {
                @self::htpPut($snap, $cur, LOCK_EX);

                return;
            }
        }

        // Bestandsinstallation (HTProtect hatte die .htaccess schon ueberschrieben) ODER
        // keine aktuelle da: HTProtect sichert VOR jedem Schreiben -> das AELTESTE
        // Nicht-HTProtect-Backup IST die Vor-HTProtect-.htaccess. So bekommen auch
        // Bestandsinstallationen ihr echtes Original zurueck (solange das erste Backup
        // nicht wegrotiert wurde).
        $orig = self::oldestForeignBackup();

        if ($orig !== null) {
            @self::htpPut($snap, $orig, LOCK_EX);

            return;
        }

        // Wirklich nichts wiederherstellbar: gab es nie eine .htaccess -> loeschen als
        // "Original"; sonst unbekannt -> Assistent bietet Joomla-Standard.
        @self::htpPut($snap, is_file($path) ? "# HTP_ORIGINAL_UNKNOWN\n" : "# HTP_NO_ORIGINAL\n", LOCK_EX);
    }

    /**
     * Aeltestes Backup, das NICHT von HTProtect stammt = die .htaccess VOR HTProtects
     * erstem Schreiben (HTProtect sichert vor jedem Schreiben; nur das allererste Backup
     * ist noch fremd). Grundlage, um auch Bestandsinstallationen ihr echtes Original
     * anzubieten.
     *
     * @return string|null
     */
    private static function oldestForeignBackup()
    {
        $dir = self::backupDir();

        if (!is_dir($dir)) {
            return null;
        }

        $files = @scandir($dir);

        if (!is_array($files)) {
            return null;
        }

        sort($files); // Dateiname traegt den Zeitstempel -> lexikalisch = chronologisch

        foreach ($files as $f) {
            if (!self::isBackupName($f)) {
                continue;
            }

            $c = (string) @file_get_contents($dir . '/' . $f);

            if ($c !== '' && strpos($c, 'htprotect-shield:begin') === false) {
                return $c;
            }
        }

        return null;
    }

    /**
     * Welche Wiederherstellung ist moeglich?
     *   'original' = echte Vor-HTProtect-.htaccess liegt vor
     *   'none'     = es gab keine .htaccess (Wiederherstellen = loeschen)
     *   'unknown'  = nicht erfasst -> Assistent bietet Joomla-Standard/loeschen
     *
     * @return string
     */
    public static function originalKind()
    {
        // Lazy: erfasst das Original (aus aktueller .htaccess ODER aeltestem Backup),
        // falls beim ersten Schreiben noch nicht geschehen - so bekommen auch
        // Bestandsinstallationen ihr Original beim ersten Assistent-Aufruf.
        self::snapshotOriginalOnce();

        $snap = self::originalSnapshotPath();

        if ($snap === '' || !is_file($snap)) {
            return 'unknown';
        }

        $c = ltrim((string) @file_get_contents($snap));

        if (strpos($c, '# HTP_NO_ORIGINAL') === 0) {
            return 'none';
        }

        if ($c === '' || strpos($c, '# HTP_ORIGINAL_UNKNOWN') === 0) {
            return 'unknown';
        }

        return 'original';
    }

    /**
     * Haupt-.htaccess beim Deinstallieren zuruecksetzen. HTProtect geht ja -
     * daher normal (0644), NICHT gehaertet.
     *
     * @param   string  $mode  'original' | 'default' | 'delete' | 'leave'
     *
     * @return boolean
     */
    public static function restoreMainHtaccess($mode)
    {
        $path = self::htaccessPath();

        if (is_file($path) && !is_writable($path)) {
            @self::htpChmod($path, 0644);
        }

        if ($mode === 'leave') {
            return true;
        }

        if ($mode === 'original') {
            $kind = self::originalKind();

            if ($kind === 'original') {
                return self::writePlain($path, (string) @file_get_contents(self::originalSnapshotPath()));
            }

            if ($kind === 'none') {
                return self::deleteHtaccess($path);
            }

            $mode = 'default';
        }

        if ($mode === 'default') {
            // Primaer: LIVE htaccess.txt der Installation - per Definition exakt die
            // richtige Vorlage dieser Joomla-Version (wird bei Updates mitgepflegt).
            $txt = htp_docroot() . '/htaccess.txt';

            if (is_file($txt)) {
                return self::writePlain($path, self::softenRiskyDirectives((string) @file_get_contents($txt)));
            }

            // Fallback (htaccess.txt manuell geloescht): gebuendelte, versions-gematchte
            // Vorlage (1:1 von echten Joomla-Installationen gezogen, sha256-verifiziert).
            $tpl = self::bundledDefaultHtaccess();

            if ($tpl !== '') {
                return self::writePlain($path, self::softenRiskyDirectives($tpl));
            }

            return self::deleteHtaccess($path);
        }

        if ($mode === 'delete') {
            return self::deleteHtaccess($path);
        }

        return false;
    }

    /**
     * Datei normal (0644) schreiben - ohne HTProtects 0444-Haertung.
     *
     * @param   string  $path
     * @param   string  $content
     *
     * @return boolean
     */
    private static function writePlain($path, $content)
    {
        if (@self::htpPut($path, $content, LOCK_EX) === false) {
            return false;
        }

        @self::htpChmod($path, 0644);

        return true;
    }

    /**
     * Die von Joomla selbst als "Can be commented out if causes errors" markierten
     * Options-Zeilen praeventiv auskommentieren. 'Options +FollowSymlinks' (bzw.
     * '+SymLinksIfOwnerMatch') und 'Options -Indexes' loesen auf Hosts, die das
     * Ueberschreiben von Options per .htaccess verbieten (AllowOverride ohne
     * Options), einen HTTP 500 aus. Beim Wiederherstellen der Joomla-Standard-
     * .htaccess - z. B. nach der (womoeglich schon frustrierten) Deinstallation -
     * darf die Seite NIE mit 500 stehen bleiben. Auskommentieren ist gefahrlos:
     * mod_rewrite laeuft auf solchen Hosts ohnehin (der Server setzt FollowSymlinks
     * serverseitig), und die Verzeichnis-Auflistung deckt das nachfolgende
     * 'IndexIgnore *' (mod_autoindex) ab. Idempotent (bereits kommentierte Zeilen
     * beginnen mit '#' und matchen nicht).
     *
     * @param   string  $content  .htaccess-Inhalt
     *
     * @return string
     */
    private static function softenRiskyDirectives($content)
    {
        if ((string) $content === '') {
            return $content;
        }

        // 500-riskante Options-Zeilen auskommentieren (Host-abhaengig).
        $content = preg_replace(
            '/^([ \t]*)(Options[ \t]+[+-](?:FollowSymlinks|SymLinksIfOwnerMatch|Indexes)\b[^\r\n]*)/im',
            '$1# $2   # HTProtect: auskommentiert (kann je nach Host HTTP 500 ausloesen)',
            $content
        );

        // Joomlas "GZIP & BROTLI"-Block (praekomprimierte .gz-Auslieferung) auch auf dem
        // Restore-Weg entschaerfen. Auf Servern, die .gz selbst encodieren (mod_mime), addiert
        // dessen "Header set Content-Encoding gzip" ein zweites Content-Encoding -> der Browser
        // scheitert am doppelten Entpacken (ERR_CONTENT_DECODING_FAILED, ganze Seite ungestylt).
        // Gleiche Linie wie der Ruleset-7-Fix im aktiven Schild - hier fuer die (Un-)Install-
        // Wiederherstellung (Joomla liefert diesen Block selbst mit eigener Warnung). Der ganze
        // Block wird inert kommentiert; nur Kommentar-/Header-/RewriteRule-Zeilen -> kein HTTP 500.
        $content = preg_replace_callback(
            '/^[ \t]*## GZIP & BROTLI\b.*?<IfModule[ \t]+mod_headers(?:\.c)?>.*?<\/IfModule>/ims',
            function ($m) {
                return preg_replace('/^(?![ \t]*#)([ \t]*)(\S[^\r\n]*)/m', '$1# $2', $m[0]);
            },
            $content
        );

        return $content;
    }

    /**
     * .htaccess entfernen (0444-Haertung vorher aufheben).
     *
     * @param   string  $path
     *
     * @return boolean
     */
    private static function deleteHtaccess($path)
    {
        if (!is_file($path)) {
            return true;
        }

        @self::htpChmod($path, 0644);

        return (bool) @unlink($path);
    }

    /**
     * Gebuendelte Joomla-Standard-.htaccess (Fallback, falls die live htaccess.txt
     * fehlt). Versions-gematcht: Joomla <= 3 -> klassische Vorlage; >= 4 -> moderne
     * (IfModule-Wrapper/API-SEF/GZIP; J4/5/6 identisch). Beide 1:1 von echten
     * Joomla-Installationen gezogen + per sha256 verifiziert (admin/defaults/).
     *
     * @return string  Vorlageninhalt oder '' (nicht verfuegbar)
     */
    private static function bundledDefaultHtaccess()
    {
        $major = defined('JVERSION') ? (int) JVERSION : 5;
        $file  = dirname(HTP_CORE) . '/defaults/htaccess-' . ($major <= 3 ? 'j3' : 'modern') . '.txt';

        return is_file($file) ? (string) @file_get_contents($file) : '';
    }

    /**
     * Liste der Sicherungen (neueste zuerst).
     *
     * @return array
     */
    public static function backups()
    {
        $dir   = self::backupDir();
        $items = array();

        if (!is_dir($dir)) {
            return $items;
        }

        $files = @scandir($dir);

        if (!is_array($files)) {
            return $items;
        }

        foreach ($files as $file) {
            if (!self::isBackupName($file)) {
                continue;
            }

            $full    = $dir . '/' . $file;
            $items[] = array(
                'name'  => $file,
                'size'  => (int) @filesize($full),
                'mtime' => (int) @filemtime($full),
            );
        }

        usort($items, 'HtpStorage::sortBackups');

        return $items;
    }

    /**
     * usort-Callback: neueste zuerst.
     *
     * @param   array  $a  Eintrag
     * @param   array  $b  Eintrag
     *
     * @return integer
     */
    public static function sortBackups($a, $b)
    {
        if ($a['mtime'] === $b['mtime']) {
            return strcmp($b['name'], $a['name']);
        }

        return $b['mtime'] - $a['mtime'];
    }

    /**
     * Inhalt einer Sicherung lesen.
     *
     * @param   string  $name  Dateiname der Sicherung
     *
     * @return string|null null bei ungueltigem Namen
     */
    public static function readBackup($name)
    {
        if (!self::isBackupName($name)) {
            return null;
        }

        $full = self::backupDir() . '/' . $name;

        return is_file($full) ? (string) @file_get_contents($full) : null;
    }

    /**
     * Sicherung als aktive .htaccess wiederherstellen.
     *
     * @param   string  $name  Dateiname der Sicherung
     *
     * @return boolean
     */
    public static function restore($name)
    {
        $content = self::readBackup($name);

        if ($content === null) {
            return false;
        }

        self::backup('prerestore');

        return self::write($content);
    }

    /**
     * Sicherung loeschen.
     *
     * @param   string  $name  Dateiname der Sicherung
     *
     * @return boolean
     */
    public static function deleteBackup($name)
    {
        if (!self::isBackupName($name)) {
            return false;
        }

        $full = self::backupDir() . '/' . $name;

        return is_file($full) ? @unlink($full) : false;
    }

    /**
     * Strenge Namenspruefung gegen Pfad-Tricks.
     *
     * @param   string  $name  Kandidat
     *
     * @return boolean
     */
    public static function isBackupName($name)
    {
        return is_string($name)
            && (bool) preg_match('/^htaccess-[0-9]{8}-[0-9]{6}-[a-z0-9]{1,24}\.bak$/', $name);
    }

    /**
     * Alte Sicherungen ausduennen.
     *
     * @return void
     */
    private static function prune()
    {
        $items = self::backups();

        if (count($items) <= self::KEEP_BACKUPS) {
            return;
        }

        $dir = self::backupDir();

        foreach (array_slice($items, self::KEEP_BACKUPS) as $item) {
            @unlink($dir . '/' . $item['name']);
        }
    }

    /**
     * Schutzdateien in ein Sicherungs-Verzeichnis legen.
     *
     * @param   string  $dir  Verzeichnis
     *
     * @return void
     */
    private static function protectDir($dir)
    {
        $deny = "<IfModule mod_authz_core.c>\n    Require all denied\n</IfModule>\n"
            . "<IfModule !mod_authz_core.c>\n    Order allow,deny\n    Deny from all\n</IfModule>\n";

        @self::htpPut($dir . '/.htaccess', $deny);
        @self::htpPut($dir . '/index.html', '<!DOCTYPE html><title></title>');
    }

    // ─────────────────────────────────────────────────────────────────
    //  Malware-Scan/-Loeschen/-Editor -> nach HtpScanner (scanner.php) ausgelagert.
    //  Diese Datei bleibt die "langweilige" .htaccess-/Backup-/tmp-Engine (reine
    //  Half-A: schreibt/sichert, sucht/loescht aber KEINE fremden PHP-Dateien).
    //  Die folgenden duennen Wrapper delegieren an die bootstrap-OPTIONALE Klasse
    //  HtpScanner; fehlt sie (z. B. von einem Server-Virenscanner ausgeweidet),
    //  liefern sie sichere Defaults - der Rest von HTProtect laeuft ungestoert.
    // ─────────────────────────────────────────────────────────────────

    /** Delegiert an HtpScanner (bootstrap-optional); fehlt sie -> sicherer Default. */
    public static function scanMaliciousHtaccess()
    {
        return class_exists('HtpScanner') ? HtpScanner::scanMaliciousHtaccess() : array();
    }

    /** Delegiert an HtpScanner (bootstrap-optional); fehlt sie -> sicherer Default. */
    public static function deleteMaliciousHtaccess()
    {
        return class_exists('HtpScanner') ? HtpScanner::deleteMaliciousHtaccess() : array('deleted' => 0, 'failed' => 0);
    }

    /** Delegiert an HtpScanner (bootstrap-optional); fehlt sie -> sicherer Default. */
    public static function malwareSignature($content)
    {
        return class_exists('HtpScanner') ? HtpScanner::malwareSignature($content) : '';
    }

    /** Delegiert an HtpScanner (bootstrap-optional); fehlt sie -> fail-open (kein Fund). */
    public static function scanMalwareUnder(array $absPaths, $cap = 800, $budget = 6.0)
    {
        return class_exists('HtpScanner') ? HtpScanner::scanMalwareUnder($absPaths, $cap, $budget) : array();
    }

    /** Delegiert an HtpScanner (bootstrap-optional); fehlt sie -> sofort fertig, kein Fund. */
    public static function scanMalwareChunk(&$state, $budget = 4.0, $hitCap = 300, $dirCap = self::MAL_SCAN_DIR_HARD)
    {
        return class_exists('HtpScanner')
            ? HtpScanner::scanMalwareChunk($state, $budget, $hitCap, $dirCap)
            : array('done' => true, 'hits' => array(), 'seen' => 0, 'files' => 0, 'skipped' => 0, 'queued' => 0);
    }

    /** Delegiert an HtpScanner (bootstrap-optional); fehlt sie -> sicherer Default. */
    public static function scanStateRead($id)
    {
        return class_exists('HtpScanner') ? HtpScanner::scanStateRead($id) : null;
    }

    /** Delegiert an HtpScanner (bootstrap-optional); fehlt sie -> No-Op. */
    public static function scanStateCleanup($maxAgeSeconds = 3600)
    {
        return class_exists('HtpScanner') ? HtpScanner::scanStateCleanup($maxAgeSeconds) : null;
    }

    /** Delegiert an HtpScanner (bootstrap-optional); fehlt sie -> sicherer Default. */
    public static function scanStateNewId()
    {
        return class_exists('HtpScanner') ? HtpScanner::scanStateNewId() : '';
    }

    /** Delegiert an HtpScanner (bootstrap-optional); fehlt sie -> No-Op. */
    public static function scanStateDelete($id)
    {
        return class_exists('HtpScanner') ? HtpScanner::scanStateDelete($id) : null;
    }

    /** Delegiert an HtpScanner (bootstrap-optional); fehlt sie -> sicherer Default. */
    public static function scanStateWrite($id, array $state)
    {
        return class_exists('HtpScanner') ? HtpScanner::scanStateWrite($id, $state) : false;
    }

    /** Delegiert an HtpScanner (bootstrap-optional); fehlt sie -> sicherer Default. */
    public static function malwareBaselineRead()
    {
        return class_exists('HtpScanner') ? HtpScanner::malwareBaselineRead() : array('ts' => 0, 'ver' => '', 'hits' => array());
    }

    /** Delegiert an HtpScanner (bootstrap-optional); fehlt sie -> No-Op. */
    public static function malwareBaselineWrite($ts, $ver, array $hits)
    {
        return class_exists('HtpScanner') ? HtpScanner::malwareBaselineWrite($ts, $ver, $hits) : null;
    }

    /** Delegiert an HtpScanner (bootstrap-optional); fehlt sie -> sicherer Default. */
    public static function deleteMalwareFile($rel)
    {
        return class_exists('HtpScanner') ? HtpScanner::deleteMalwareFile($rel) : false;
    }

    /** Vom Hoster gesperrte Datei entsperr-scannen (einordnen). Delegiert an HtpScanner (bootstrap-optional). */
    public static function quarantineScanFile($rel)
    {
        return class_exists('HtpScanner') ? HtpScanner::quarantineScanFile($rel) : array('ok' => false, 'readable' => false, 'malware' => false, 'reason' => '');
    }

    /** Vom Hoster gesperrte Datei loeschen (nicht blind - s. Engine). Delegiert an HtpScanner. */
    public static function quarantineDeleteFile($rel, $force = false)
    {
        return class_exists('HtpScanner') ? HtpScanner::quarantineDeleteFile($rel, $force) : array('ok' => false, 'code' => 'no_engine');
    }

    /** Rechte einer faelschlich gesperrten Datei wiederherstellen (nicht blind - s. Engine). Delegiert an HtpScanner. */
    public static function quarantineRestoreFile($rel)
    {
        return class_exists('HtpScanner') ? HtpScanner::quarantineRestoreFile($rel) : array('ok' => false, 'code' => 'no_engine');
    }

    /** Delegiert an HtpScanner (bootstrap-optional); fehlt sie -> sicherer Default. */
    public static function readMalwareFile($rel)
    {
        return class_exists('HtpScanner') ? HtpScanner::readMalwareFile($rel) : null;
    }

    /** Delegiert an HtpScanner (bootstrap-optional); fehlt sie -> sicherer Default. */
    public static function saveMalwareFile($rel, $content, $expectedBytes = -1)
    {
        return class_exists('HtpScanner') ? HtpScanner::saveMalwareFile($rel, $content, $expectedBytes) : array('ok' => false);
    }

    /** Delegiert an HtpScanner (bootstrap-optional); fehlt sie -> sicherer Default. */
    public static function appendEditChunk($rel, $xfer, $seq, $bytes)
    {
        return class_exists('HtpScanner') ? HtpScanner::appendEditChunk($rel, $xfer, $seq, $bytes) : array('ok' => false);
    }

    /** Delegiert an HtpScanner (bootstrap-optional); fehlt sie -> sicherer Default. */
    public static function commitMalwareFile($rel, $xfer, $expectedBytes)
    {
        return class_exists('HtpScanner') ? HtpScanner::commitMalwareFile($rel, $xfer, $expectedBytes) : array('ok' => false);
    }

    /** Delegiert an HtpScanner (bootstrap-optional); fehlt sie -> sicherer Default. */
    public static function deleteAllMalwareFiles()
    {
        return class_exists('HtpScanner') ? HtpScanner::deleteAllMalwareFiles() : array('deleted' => 0, 'failed' => 0);
    }

    /** Delegiert an HtpScanner (bootstrap-optional); fehlt sie -> sicherer Default. */
    public static function normalizeWhitelist($list)
    {
        return class_exists('HtpScanner') ? HtpScanner::normalizeWhitelist($list) : array();
    }
}
