<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * Upload-Ordner-Haertung: legt in Upload-/System-Ordnern (images, media,
 * tmp, cache ...) eine eigene Mini-.htaccess ab, die PHP-Ausfuehrung dort
 * sperrt. Wirkt als zweite Verteidigungslinie - auch dann noch, wenn ein
 * Angreifer die Haupt-.htaccess loescht oder ueberschreibt, und gegen
 * unbekannte kuenftige Upload-Luecken (die Beute, eine PHP-Shell, laeuft
 * dort nicht mehr).
 */

defined('_JEXEC') or die;

class HtpDirShield
{
    const MARK_BEGIN = '# htprotect-dirshield:begin';
    const MARK_END   = '# htprotect-dirshield:end';

    /**
     * Joomla-Code-/Admin-Verzeichnisse: dort ist PHP-Ausfuehrung legitim und
     * teils ZWINGEND (z. B. administrator/components/com_joomlaupdate/restore.php
     * beim Kern-Update). Eine PHP-Sperre hier bricht Updates/Backend. Diese
     * Wurzeln (und alles darunter) werden deshalb NIE gehaertet - egal was in
     * u_dirs steht. Uploads landen ausschliesslich in images/media/files, nie
     * hier; der Standard-Default ist davon also nicht betroffen (kein FP).
     */
    const PROTECTED_ROOTS = array(
        'administrator', 'components', 'modules', 'plugins', 'libraries',
        'templates', 'api', 'cli', 'includes', 'layouts', 'bin',
    );

    /**
     * Standard-Ordner (spiegelt HtpConfig::defaultHardenDirs()).
     *
     * @return array
     */
    public static function defaultDirs()
    {
        return HtpConfig::defaultHardenDirs();
    }

    /**
     * Ist der Ordner ein Joomla-Code-/Admin-Verzeichnis, das NIE gehaertet
     * werden darf (PHP dort legitim/zwingend)? Deckt auch com_joomlaupdate
     * explizit ab (Kern-Updater). Rein, testbar, kein Dateisystem-Zugriff.
     *
     * @param   string  $dir  Relativer Ordnerpfad
     *
     * @return boolean
     */
    public static function isProtectedCodeDir($dir)
    {
        $dir = strtolower(trim(str_replace('\\', '/', (string) $dir), '/'));

        if ($dir === '') {
            return false;
        }

        // Kern-Updater unter keinen Umstaenden sperren (Belt-and-suspenders;
        // liegt ohnehin unter administrator/components).
        if (strpos($dir, 'com_joomlaupdate') !== false) {
            return true;
        }

        foreach (self::PROTECTED_ROOTS as $root) {
            if ($dir === $root || strpos($dir, $root . '/') === 0) {
                return true;
            }
        }

        return false;
    }

    /**
     * Inhalt der Mini-.htaccess (PHP-Ausfuehrung sperren, inkl.
     * Doppel-Endungen wie shell.php.jpg).
     *
     * @return string
     */
    public static function content()
    {
        $lines = array(
            self::MARK_BEGIN . ' v=' . HTP_VERSION,
            '# ' . htp_tc('COM_HTPROTECT_DS_COMMENT1', 'Von HTProtect verwaltet: PHP-Ausführung in diesem Ordner gesperrt.'),
            '# ' . htp_tc('COM_HTPROTECT_DS_COMMENT2', 'Wirkt auch, wenn die Haupt-.htaccess fehlt. Bei Bedarf in der'),
            '# ' . htp_tc('COM_HTPROTECT_DS_COMMENT3', 'Komponente unter "Upload-Ordner-Härtung" abschaltbar.'),
            '<FilesMatch "(?i)\.(php[0-9]*|phtml|pht|phps|phar)(\.|$)">',
            '    <IfModule mod_authz_core.c>',
            '        Require all denied',
            '    </IfModule>',
            '    <IfModule !mod_authz_core.c>',
            '        Order allow,deny',
            '        Deny from all',
            '    </IfModule>',
            '</FilesMatch>',
            self::MARK_END,
        );

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

    /**
     * Zu haertende Ordner ermitteln: konfigurierte Liste, nur vorhandene,
     * ohne Ueberschneidung mit PHP-Freigaben (s_dirs_php).
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array Liste relativer Ordnerpfade
     */
    public static function targetDirs($cfg)
    {
        $dirs = isset($cfg['u_dirs']) && is_array($cfg['u_dirs']) ? $cfg['u_dirs'] : self::defaultDirs();
        $php  = isset($cfg['s_dirs_php']) && is_array($cfg['s_dirs_php']) ? $cfg['s_dirs_php'] : array();
        $root = htp_docroot();
        $out  = array();

        foreach ($dirs as $dir) {
            $dir = trim(str_replace('\\', '/', (string) $dir), '/');

            if ($dir === '' || strpos($dir, '..') !== false) {
                continue;
            }

            // Code-/Admin-Verzeichnisse nie haerten (bricht sonst z. B. das
            // Kern-Update ueber administrator/components/com_joomlaupdate).
            if (self::isProtectedCodeDir($dir)) {
                continue;
            }

            if (self::conflictsWithPhp($dir, $php)) {
                continue;
            }

            if (is_dir($root . '/' . $dir)) {
                $out[$dir] = true;
            }
        }

        return array_keys($out);
    }

    /**
     * Liegt der Ordner in einem PHP-freigegebenen Ordner (oder ist es
     * derselbe)? Dann nicht haerten - der Nutzer will dort PHP.
     *
     * @param   string  $dir      Kandidat
     * @param   array   $phpDirs  PHP-Freigaben
     *
     * @return boolean
     */
    private static function conflictsWithPhp($dir, $phpDirs)
    {
        foreach ($phpDirs as $phpDir) {
            $phpDir = trim(str_replace('\\', '/', (string) $phpDir), '/');

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

            // Konflikt in BEIDE Richtungen: der Haerten-Ordner liegt IM
            // PHP-Ordner (oder ist er) ODER ein PHP-Ordner liegt IM
            // Haerten-Ordner. Im zweiten Fall wuerde die rekursiv wirkende
            // Mini-.htaccess die GEWOLLTE PHP-Ausfuehrung im Unterordner
            // sperren (z. B. u_dirs=images + s_dirs_php=images/gallery).
            if ($dir === $phpDir
                || strpos($dir . '/', $phpDir . '/') === 0
                || strpos($phpDir . '/', $dir . '/') === 0) {
                return true;
            }
        }

        return false;
    }

    /**
     * Haertung anwenden. Pro Ordner: vorhandenen eigenen Block ersetzen,
     * fremden Inhalt erhalten, atomar schreiben.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array ['written' => int, 'foreign' => int, 'failed' => int, 'dirs' => array]
     */
    public static function apply(&$cfg)
    {
        $root    = htp_docroot();
        $written = 0;
        $foreign = 0;
        $failed  = 0;
        $detail  = array();

        $targets = self::targetDirs($cfg);

        // Aufraeumen VOR dem Anwenden (idempotent, im Normalfall ein No-op):
        // (a) Verwaiste eigene .htaccess entfernen - jeder Ordner mit unserem
        //     Block, der NICHT (mehr) Ziel ist. Faengt "Eintrag aus der Liste
        //     entfernt, Datei blieb liegen" (P2W 2026-08-18) UND Code-/Admin-
        //     Ordner UND Alt-Reste (administrator/cache|logs, 2.2.0-2.2.20).
        // (b) Code-/Admin-Eintraege dauerhaft aus u_dirs streichen (per Referenz;
        //     der Aufrufer speichert danach) - verhindert erneutes Haerten + 403
        //     aufs Kern-Update ueber administrator/components/com_joomlaupdate.
        $healed = self::reconcileHtaccess($cfg, $targets);
        self::pruneProtectedDirs($cfg);

        foreach ($targets as $dir) {
            $file = $root . '/' . $dir . '/.htaccess';
            $orig = is_file($file) ? (string) @file_get_contents($file) : null;
            $rest = $orig !== null ? self::stripBlock($orig) : '';
            $hasForeign = $rest !== '';
            $content = $hasForeign ? $rest . "\n\n" . self::content() . "\n" : self::content() . "\n";

            if (HtpStorage::writeTo($file, $content)) {
                $written++;

                if ($hasForeign) {
                    $foreign++;
                }

                $detail[$dir] = $hasForeign ? 'foreign-preserved' : 'written';
            } else {
                $failed++;
                $detail[$dir] = 'failed';
            }
        }

        return array(
            'written' => $written,
            'foreign' => $foreign,
            'failed'  => $failed,
            'dirs'    => $detail,
            'healed'  => $healed,
        );
    }

    /**
     * Verwaiste eigene Mini-.htaccess entfernen: aus JEDEM Ordner, der unseren
     * Block traegt, aber nicht (mehr) Ziel der Haertung ist. Deckt drei Faelle:
     * (1) ein Eintrag wurde aus der Upload-Ordner-Haertung entfernt, die Datei
     * blieb aber liegen (P2W 2026-08-18); (2) ein Code-/Admin-Ordner ist jetzt
     * hart ausgeschlossen (isProtectedCodeDir); (3) Alt-Reste administrator/
     * cache|logs aus dem 2.2.0-2.2.20-Default. Kandidaten sind gebounded
     * (u_dirs + Defaults + Waechter-Manifest + Code-/Admin-Wurzeln) - kein
     * Dateisystem-Walk. Entfernt wird IMMER nur unser eigener Block; Fremd-
     * inhalt in derselben Datei bleibt unangetastet. Idempotent.
     *
     * @param   array  $cfg      Konfiguration
     * @param   array  $targets  Aktuelle Ziel-Ordner (targetDirs) - werden NIE angefasst
     *
     * @return integer Anzahl bereinigter Dateien
     */
    public static function reconcileHtaccess($cfg, $targets)
    {
        $root      = htp_docroot();
        $targetSet = array();

        foreach ($targets as $t) {
            $targetSet[trim(str_replace('\\', '/', (string) $t), '/')] = true;
        }

        // Kandidaten-Ordner (dedupliziert) - Quellen, in denen wir je geschrieben
        // haben koennten, ohne das gesamte Dateisystem zu durchsuchen.
        $cand    = array();
        $sources = array(
            isset($cfg['u_dirs']) && is_array($cfg['u_dirs']) ? $cfg['u_dirs'] : array(),
            self::defaultDirs(),
            self::PROTECTED_ROOTS,
            array(
                'administrator/cache', 'administrator/logs', 'administrator/components',
                'administrator/modules', 'administrator/templates', 'administrator/manifests',
            ),
        );

        foreach ($sources as $list) {
            foreach ($list as $d) {
                $d = trim(str_replace('\\', '/', (string) $d), '/');

                if ($d !== '' && strpos($d, '..') === false) {
                    $cand[$d] = true;
                }
            }
        }

        // Waechter-Manifest: alle bekannten */.htaccess-Ordner. Faengt einen
        // gerade aus u_dirs entfernten Eintrag, dessen Datei noch existiert
        // (apply() laeuft VOR remember(), das Manifest ist hier noch der alte).
        if (isset($cfg['watch_manifest']) && is_array($cfg['watch_manifest'])) {
            foreach (array_keys($cfg['watch_manifest']) as $rel) {
                $rel = trim(str_replace('\\', '/', (string) $rel), '/');

                if (substr($rel, -10) === '/.htaccess') {
                    $d = substr($rel, 0, -10);

                    if ($d !== '' && strpos($d, '..') === false) {
                        $cand[$d] = true;
                    }
                }
            }
        }

        $removed = 0;

        foreach (array_keys($cand) as $dir) {
            if (isset($targetSet[$dir])) {
                continue; // gewolltes Ziel -> behalten (wird gleich neu geschrieben)
            }

            $file = $root . '/' . $dir . '/.htaccess';

            if (!is_file($file)) {
                continue;
            }

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

            if (strpos($content, self::MARK_BEGIN) === false) {
                continue; // nicht von uns -> nie anfassen
            }

            $rest = self::stripBlock($content);

            if ($rest === '') {
                @chmod($file, 0644); // 0444-Haertung vor dem Entfernen aufheben

                if (@unlink($file)) {
                    $removed++;
                }
            } elseif (HtpStorage::writeTo($file, $rest . "\n")) {
                $removed++;
            }
        }

        return $removed;
    }

    /**
     * Code-/Admin-Ordner dauerhaft aus der gespeicherten u_dirs-Liste streichen
     * (per Referenz; der Aufrufer speichert $cfg danach). Zusaetzlich sofort
     * persistiert (saveKeys) als Fallback. Idempotent: steht kein geschuetzter
     * Ordner drin, wird nichts geaendert und nichts geschrieben.
     *
     * @param   array  $cfg  Konfiguration (Referenz)
     *
     * @return array Gestrichene Ordner
     */
    public static function pruneProtectedDirs(&$cfg)
    {
        $saved = isset($cfg['u_dirs']) && is_array($cfg['u_dirs']) ? $cfg['u_dirs'] : array();

        $clean  = array();
        $seen   = array();
        $pruned = array();

        foreach ($saved as $dir) {
            $norm = trim(str_replace('\\', '/', (string) $dir), '/');

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

            if (self::isProtectedCodeDir($norm)) {
                $pruned[$norm] = true;
                continue;
            }

            if (!isset($seen[$norm])) {
                $seen[$norm] = true;
                $clean[]     = $norm;
            }
        }

        if (!empty($pruned)) {
            $cfg['u_dirs'] = $clean; // per Referenz -> Aufrufer speichert die saubere Liste

            if (class_exists('HtpConfig') && method_exists('HtpConfig', 'saveKeys')) {
                HtpConfig::saveKeys(array('u_dirs' => $clean)); // Fallback, falls Aufrufer nicht speichert
            }
        }

        return array_keys($pruned);
    }

    /**
     * Haertung entfernen: eigenen Block aus allen konfigurierten Ordnern
     * loeschen; Datei loeschen, wenn danach leer.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array ['removed' => int]
     */
    public static function remove($cfg)
    {
        $root    = htp_docroot();
        $removed = 0;

        // Auch Standardordner mit aufnehmen, damit nichts liegen bleibt
        $dirs = array_unique(array_merge(
            isset($cfg['u_dirs']) && is_array($cfg['u_dirs']) ? $cfg['u_dirs'] : array(),
            self::defaultDirs()
        ));

        foreach ($dirs as $dir) {
            $dir  = trim(str_replace('\\', '/', (string) $dir), '/');
            $file = $root . '/' . $dir . '/.htaccess';

            if ($dir === '' || !is_file($file)) {
                continue;
            }

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

            if (strpos($content, self::MARK_BEGIN) === false) {
                continue;
            }

            $rest = self::stripBlock($content);

            if ($rest === '') {
                @chmod($file, 0644); // 0444-Haertung vor dem Entfernen aufheben

                if (@unlink($file)) {
                    $removed++;
                }
            } elseif (HtpStorage::writeTo($file, $rest . "\n")) {
                $removed++;
            }
        }

        return array('removed' => $removed);
    }

    /**
     * Status je Ordner (fuer die UI).
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array dir => 'protected'|'foreign'|'unprotected'|'missing'|'conflict'
     */
    public static function status($cfg)
    {
        $root = htp_docroot();
        $php  = isset($cfg['s_dirs_php']) && is_array($cfg['s_dirs_php']) ? $cfg['s_dirs_php'] : array();
        $dirs = isset($cfg['u_dirs']) && is_array($cfg['u_dirs']) ? $cfg['u_dirs'] : self::defaultDirs();
        $out  = array();

        foreach ($dirs as $dir) {
            $dir = trim(str_replace('\\', '/', (string) $dir), '/');

            if ($dir === '' || self::isProtectedCodeDir($dir)) {
                continue; // Code-/Admin-Ordner werden nie gehaertet -> nicht anzeigen
            }

            if (self::conflictsWithPhp($dir, $php)) {
                $out[$dir] = 'conflict';
                continue;
            }

            if (!is_dir($root . '/' . $dir)) {
                $out[$dir] = 'missing';
                continue;
            }

            $file = $root . '/' . $dir . '/.htaccess';

            if (!is_file($file)) {
                $out[$dir] = 'unprotected';
                continue;
            }

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

            if (strpos($content, self::MARK_BEGIN) !== false) {
                $out[$dir] = self::stripBlock($content) !== '' ? 'foreign' : 'protected';
            } else {
                $out[$dir] = 'unprotected';
            }
        }

        return $out;
    }

    /**
     * Aktuell von uns geschuetzte Mini-.htaccess-Dateien (relativ zum Root)
     * - fuer den Waechter-Manifest.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array Liste relativer Pfade (z. B. 'images/.htaccess')
     */
    public static function managedFiles($cfg)
    {
        $root = htp_docroot();
        $out  = array();

        $dirs = array_unique(array_merge(
            isset($cfg['u_dirs']) && is_array($cfg['u_dirs']) ? $cfg['u_dirs'] : array(),
            self::defaultDirs()
        ));

        foreach ($dirs as $dir) {
            $dir  = trim(str_replace('\\', '/', (string) $dir), '/');
            $file = $root . '/' . $dir . '/.htaccess';

            if ($dir === '' || self::isProtectedCodeDir($dir) || !is_file($file)) {
                continue;
            }

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

            if (strpos($content, self::MARK_BEGIN) !== false) {
                $out[] = $dir . '/.htaccess';
            }
        }

        return $out;
    }

    /**
     * Wirksamkeitstest: eine Test-PHP-Datei im ersten geschuetzten Ordner
     * ablegen, abrufen (403 erwartet) und wieder loeschen. Liefert einen
     * lesbaren Hinweis fuer die UI - kein Fehler, wenn der Ordner inert ist
     * (AllowOverride None greift dann die Haupt-.htaccess).
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array ['done' => bool, 'ok' => bool, 'status' => int, 'message' => string]
     */
    public static function canaryTest($cfg)
    {
        if (!class_exists('HtpProbe') || !HtpProbe::available()) {
            return array('done' => false, 'ok' => false, 'status' => 0, 'message' => htp_t('COM_HTPROTECT_CANARY_NOHTTP'));
        }

        $root = htp_docroot();
        $dirs = self::targetDirs($cfg);

        // Bevorzugt einen Ordner ohne Unterverzeichnis-Tiefe, der existiert
        $target = '';

        foreach ($dirs as $dir) {
            if (is_writable($root . '/' . $dir)) {
                $target = $dir;
                break;
            }
        }

        if ($target === '') {
            return array('done' => false, 'ok' => false, 'status' => 0, 'message' => htp_t('COM_HTPROTECT_CANARY_NODIR'));
        }

        $name = 'htpx-dircanary-' . substr(sha1((string) mt_rand() . microtime()), 0, 8) . '.php';
        $abs  = $root . '/' . $target . '/' . $name;

        if (@file_put_contents($abs, "<?php echo 'htpx';\n") === false) {
            return array('done' => false, 'ok' => false, 'status' => 0, 'message' => htp_t('COM_HTPROTECT_CANARY_NOFILE'));
        }

        $url = rtrim(htp_site_url(), '/') . '/' . $target . '/' . $name;

        $probe  = HtpProbe::fetch($url, false);
        $status = (int) $probe['status'];
        @unlink($abs);

        if ($status === 403) {
            return array('done' => true, 'ok' => true, 'status' => 403, 'message' => htp_t('COM_HTPROTECT_CANARY_OK', $target));
        }

        if ($status === 200) {
            return array('done' => true, 'ok' => false, 'status' => 200, 'message' => htp_t('COM_HTPROTECT_CANARY_NOOVERRIDE', $target));
        }

        return array('done' => true, 'ok' => false, 'status' => $status, 'message' => htp_t('COM_HTPROTECT_CANARY_UNEXPECTED', $status, $target));
    }

    /**
     * Eigenen Block aus einem Mini-.htaccess-Inhalt entfernen.
     *
     * @param   string  $content  Dateiinhalt
     *
     * @return string Bereinigter Inhalt (getrimmt)
     */
    public 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);
    }
}
