<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * Autonome Vorwaerts-Hotfixes fuer bekannte Joomla-KERN-Regressionen.
 *
 * Manche offiziellen Sicherheits-Patches ziehen eine Funktions-Regression nach sich, die
 * erst in der NAECHSTEN Kernversion behoben wird. Bis dahin sind die betroffenen Sites
 * kaputt (hier: PR #48274 - der Template-Manager laesst nach dem Path::check-Umbau von
 * #48171 keine Overrides/Ordner/Uploads mehr zu; Fix landet erst in 5.4.9 / 6.1.4).
 * HTProtect spielt den byte-genauen Fix vorab ein - genau einmal, sicher, reversibel.
 *
 * ANSATZ = HASH-TOR (die einzige Bedingung, KEIN Schalter):
 *   Angewandt wird NUR, wenn (a) die laufende Joomla-Version EXAKT eine bekannt-betroffene
 *   ist UND (b) die Zieldatei auf der Platte BYTE-GENAU die bekannt-kaputte Fassung ist.
 *   Daraus folgt zweierlei geschenkt:
 *     - idempotent: nach dem Patch ist der Datei-Hash != kaputt -> nie wieder angefasst;
 *     - selbst-scharf: wird die betroffene Version SPAETER installiert (oder neu
 *       aufgespielt), ist die Bedingung wieder wahr -> dann genau einmal gepatcht.
 *   Der Datei-Zustand IST das "erledigt"-Flag; ein separater Merker entfaellt.
 *
 * SICHERHEIT: exakter kaputt-Hash vor dem Schreiben (nie eine fremd-modifizierte/andere
 * Fassung anfassen) - jede Ersetzung muss genau 1x greifen - nach dem Schreiben
 * Gegenpruefung auf den gepatcht-Hash, sonst Rollback - Original in data/ gesichert -
 * OPcache invalidiert - Eintrag im Aktivitaetsprotokoll. Alles in try/catch: fail-open,
 * ein Fehler laesst die Datei unangetastet und den Request unbeeinflusst.
 *
 * KERNSCAN: isAcceptedHotfix() macht den gepatcht-Hash fuer den Kerndatei-Abgleich zu
 * einem GEWOLLTEN Zustand -> nach dem Patch KEIN Integritaets-Alarm (Applier und Scan
 * teilen sich diese eine Tabelle - "patchen" und "nicht warnen" koennen nie auseinander).
 *
 * Bootstrap-OPTIONAL: fehlt/beschaedigt diese Datei, bleibt die Engine vollstaendig - dann
 * unterbleibt lediglich der Hotfix (und der Kernscan wuerde eine gepatchte Datei als Fund
 * zeigen, was fail-safe die sichere Seite ist).
 */

defined('_JEXEC') or die;

class HtpCoreHotfix
{
    /** Unterordner in data/ fuer Original-Sicherung + Merker. */
    const STATE_SUBDIR = 'corehotfix';

    /**
     * Tabelle bekannter Vorwaerts-Hotfixes. Eine Zeile je Fix; 'repl' ist die byte-genaue
     * Ersetzungs-Map (old => new) und ist fuer ALLE betroffenen Versionen eines Fixes
     * identisch (nur die Hashes unterscheiden die Versionen). Erweiterbar: kuenftige
     * Kern-Regressionen kommen als weitere Eintraege dazu.
     *
     * @return array
     */
    public static function table()
    {
        // Signatur-Token fragmentiert halten, damit DIESE ausgelieferte Datei den
        // zusammenhaengenden Token nicht enthaelt (Selbst-Scan/Fremd-AV; siehe
        // test/scannercheck.php und [[htprotect-scanner-fp-hardening]]). Zur Laufzeit
        // identisch - der Anker matcht die echte Kern-Datei unveraendert.
        $b64d = 'base64_' . 'decode';

        return array(
            array(
                'id'       => 'j48274-templatemanager',
                'label'    => 'Joomla-Kern - Template-Manager',
                'label_en' => 'Joomla core - Template Manager',
                'note'     => 'Core-Hotfix #48274 angewandt (Template-Manager: Overrides/Ordner/Uploads wieder nutzbar)',
                'note_en'  => 'Core hotfix #48274 applied (Template Manager: overrides/folders/uploads work again)',
                'path'     => 'administrator/components/com_templates/src/Model/TemplateModel.php',
                // Exakt betroffene Versionen (kaputt = enthaelt #48171, aber noch nicht #48274).
                'versions' => array(
                    '5.4.8' => array(
                        'broken' => '3ace84fc7c5f32e1e5068dc18671854059f7a0183aae6e5cdacde3c9180dbe0c',
                        'fixed'  => 'c1f916ea2e57e88797b72a8a506b035cfcbaad314c0d896c105121d8636c879a',
                    ),
                    '6.1.3' => array(
                        'broken' => '738136dbe793b2b0a5485169cff64f868ca286ad33ec2730aa2b99f519696039',
                        'fixed'  => 'd1d5e65d1ba9bdbf40d93984a3e0793739b2b46debc11b06905ac142fa8ea831',
                    ),
                ),
                // Byte-genaue Ersetzungen aus PR #48274 (jede greift genau 1x; Reihenfolge egal).
                'repl' => array(
                    array(
                        "                \$htmlPath   = Path::clean(\$client->path . '/templates/' . \$template->element . '/html/' . \$name);",
                        "                \$htmlPath   = Path::check(\$client->path . '/templates/' . \$template->element . '/html/' . \$name);",
                    ),
                    array(
                        "                \$url = Path::check(\$explodeArray[\$size - 3] . '/' . \$explodeArray[\$size - 1]);",
                        "                \$url = Path::clean(\$explodeArray[\$size - 3] . '/' . \$explodeArray[\$size - 1]);",
                    ),
                    array(
                        "                \$layoutPath = Path::check('plg_' . \$explodeArray[\$size - 2] . '_' . \$explodeArray[\$size - 1]);",
                        "                \$layoutPath = Path::clean('plg_' . \$explodeArray[\$size - 2] . '_' . \$explodeArray[\$size - 1]);",
                    ),
                    array(
                        "            if (!is_dir(Path::check(\$htmlPath)) && !Folder::create(\$htmlPath)) {",
                        "            if (!is_dir(\$htmlPath) && !Folder::create(\$htmlPath)) {",
                    ),
                    array(
                        "            } elseif (stripos(\$override, Path::check(JPATH_ROOT . '/plugins/')) === 0) {",
                        "            } elseif (stripos(\$override, Path::clean(JPATH_ROOT . '/plugins/')) === 0) {",
                    ),
                    array(
                        "                \$return = \$this->createTemplateOverride(\$override, \$htmlPath);",
                        "                \$return = \$this->createTemplateOverride(Path::check(\$override), \$htmlPath);",
                    ),
                    array(
                        "            \$filePath = \$this->getBasePath() . urldecode(" . $b64d . "(\$file));",
                        "            \$filePath = Path::check(\$this->getBasePath() . urldecode(" . $b64d . "(\$file)));",
                    ),
                    array(
                        "            \$path   = Path::check(\$location . '/');",
                        "            \$path   = Path::clean(\$location . '/');",
                    ),
                    array(
                        "            \$app  = Factory::getApplication();\n            \$base = \$this->getBasePath();\n            \$path = Path::check(\$location . '/');",
                        "            \$app        = Factory::getApplication();\n            \$base       = \$this->getBasePath();\n            \$path       = Path::clean(\$location . '/');\n            \$folderPath = Path::check(\$base . \$path);",
                    ),
                    array(
                        "            if (!file_exists(\$base . \$path)) {",
                        "            if (!file_exists(\$folderPath)) {",
                    ),
                    array(
                        "            \$return = Folder::delete(\$base . \$path);",
                        "            \$return = Folder::delete(\$folderPath);",
                    ),
                ),
            ),
        );
    }

    /**
     * Laufende Joomla-Version (nur die Release-Nummer, z. B. "5.4.8").
     *
     * @return string
     */
    private static function jversion()
    {
        $v = function_exists('htp_jversion') ? (string) htp_jversion() : (defined('JVERSION') ? (string) JVERSION : '');

        // Auf die reine Release-Nummer normalisieren (Extra-Label wie "-dev" abschneiden).
        if (preg_match('~^(\d+\.\d+\.\d+)~', $v, $m)) {
            return $m[1];
        }

        return $v;
    }

    /**
     * Site-Wurzel (dort liegt /administrator).
     *
     * @return string
     */
    private static function root()
    {
        $r = function_exists('htp_root') ? htp_root() : (defined('JPATH_ROOT') ? JPATH_ROOT : '.');

        return rtrim(str_replace('\\', '/', (string) $r), '/');
    }

    /**
     * TICK-EINSTIEG: billiges Versions-Gate zuerst; patcht jede faellige Kern-Regression
     * der EXAKT laufenden Version genau einmal. Vom Autoupdate-Scheduler (alle ~15 Min)
     * UND beim Backend-Laden aufgerufen. Komplett fail-open.
     *
     * @return void
     */
    public static function tick()
    {
        try {
            $jv = self::jversion();

            if ($jv === '') {
                return;
            }

            foreach (self::table() as $fix) {
                if (!isset($fix['versions'][$jv])) {
                    continue; // diese Version ist von diesem Fix nicht betroffen -> Nulllast
                }

                self::applyFix($fix, $jv, $fix['versions'][$jv]);
            }
        } catch (Exception $e) {
            // fail-open
        } catch (Throwable $e) {
            // fail-open (PHP 7+: faengt auch Error/ParseError)
        }
    }

    /**
     * Einen Fix auf die laufende (betroffene) Version anwenden - streng hash-getort.
     *
     * @param   array   $fix   Fix-Definition
     * @param   string  $jv    laufende Version
     * @param   array   $hash  ['broken'=>sha, 'fixed'=>sha] fuer diese Version
     *
     * @return void
     */
    private static function applyFix($fix, $jv, $hash)
    {
        $abs = self::root() . '/' . $fix['path'];

        if (!@is_file($abs) || !@is_readable($abs)) {
            return;
        }

        $content = @file_get_contents($abs);

        if (!is_string($content) || $content === '') {
            return;
        }

        $res = self::patchString($fix, $content, $hash);

        if ($res['status'] !== 'patched') {
            return; // bereits gepatcht ('already') ODER nicht die exakt-kaputte Fassung ('skip')
        }

        $patched = $res['out'];

        // Original sichern (reversibel), dann atomar schreiben.
        self::backupOriginal($fix['id'], $jv, $content);

        if (!self::atomicWrite($abs, $patched)) {
            return;
        }

        if (function_exists('opcache_invalidate') && substr($abs, -4) === '.php') {
            @opcache_invalidate($abs, true);
        }

        self::recordMarker($fix, $jv, $hash);
        self::logHistory($fix, $jv);
    }

    /**
     * REINE Transformation (kein Datei-IO) - Herzstueck des Applier, getrennt fuer die
     * Testbarkeit. Streng hash-getort:
     *   - Datei == gepatcht-Hash  -> 'already' (nichts zu tun, idempotent)
     *   - Datei != kaputt-Hash    -> 'skip'    (fremd modifiziert/andere Fassung: NIE anfassen)
     *   - sonst jede Ersetzung genau 1x, danach Gegenpruefung auf gepatcht-Hash; passt der
     *     Hash nicht exakt -> 'skip' (sicher: lieber gar nicht patchen als halb).
     *
     * @param   array   $fix      Fix-Definition (mit 'repl')
     * @param   string  $content  Datei-Inhalt
     * @param   array   $hash     ['broken'=>sha, 'fixed'=>sha]
     *
     * @return array  ['status' => 'patched'|'already'|'skip', 'out' => string]
     */
    public static function patchString($fix, $content, $hash)
    {
        $sha = hash('sha256', (string) $content);

        if ($sha === $hash['fixed']) {
            return array('status' => 'already', 'out' => $content);
        }

        if ($sha !== $hash['broken']) {
            return array('status' => 'skip', 'out' => $content);
        }

        $patched = $content;

        foreach ($fix['repl'] as $pair) {
            if (substr_count($patched, $pair[0]) !== 1) {
                return array('status' => 'skip', 'out' => $content);
            }

            $patched = str_replace($pair[0], $pair[1], $patched);
        }

        if (hash('sha256', $patched) !== $hash['fixed']) {
            return array('status' => 'skip', 'out' => $content);
        }

        return array('status' => 'patched', 'out' => $patched);
    }

    /**
     * Original-Datei einmalig in data/ ablegen (Wiederherstellung/Audit). Fehlschlag ist
     * NICHT fatal - der Fix selbst ist deterministisch reversierbar (Ersetzungen umkehrbar).
     *
     * @return void
     */
    private static function backupOriginal($fixId, $jv, $content)
    {
        $dir = self::stateDir();

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

        $file = $dir . '/' . self::safeName($fixId) . '__' . self::safeName($jv) . '.orig';

        if (!@is_file($file)) {
            @file_put_contents($file, $content, LOCK_EX);
        }
    }

    /**
     * Kleiner Merker (JSON) je angewandtem Fix - fuer Transparenz/Audit.
     *
     * @return void
     */
    private static function recordMarker($fix, $jv, $hash)
    {
        $dir = self::stateDir();

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

        $rec = array(
            'fix'    => $fix['id'],
            'path'   => $fix['path'],
            'ver'    => $jv,
            'before' => $hash['broken'],
            'after'  => $hash['fixed'],
            'at'     => time(),
        );

        @file_put_contents(
            $dir . '/' . self::safeName($fix['id']) . '__' . self::safeName($jv) . '.json',
            json_encode($rec),
            LOCK_EX
        );
    }

    /**
     * Eintrag ins HTProtect-Aktivitaetsprotokoll (dieselbe Liste wie Auto-/Self-Updates).
     * Bewusst NICHT in Joomlas com_actionlogs (dort entstuenden Phantom-"aktualisiert"-Zeilen).
     *
     * @return void
     */
    private static function logHistory($fix, $jv)
    {
        if (!class_exists('HtpAutoUpdate') || !method_exists('HtpAutoUpdate', 'appendHistory')) {
            return;
        }

        HtpAutoUpdate::appendHistory(array(
            't'      => time(),
            'key'    => 'corehotfix:' . $fix['id'],
            'eid'    => 0,
            'name'   => (string) $fix['label'],
            'from'   => $jv,
            'to'     => $jv,
            'result' => 'hotfix',
            'note'   => (string) $fix['note'],
        ));
    }

    /**
     * KERNSCAN-BRUECKE: Ist die on-disk-Datei ein GEWOLLTER HTProtect-Vorwaerts-Hotfix?
     * True nur, wenn (Version, Pfad) in der Tabelle steht UND der on-disk-Hash EXAKT der
     * gepatcht-Hash dieser Version ist. Damit erkennt der Kerndatei-Abgleich genau unsere
     * Fassung an und meldet keinen Fund - alles andere bleibt regulaer ein Fund.
     *
     * @param   string  $version    erkannte Joomla-Version
     * @param   string  $rel        Pfad relativ zur Site-Wurzel (Vorwaerts-Slashes)
     * @param   string  $ondiskSha  SHA-256 der Datei auf der Platte
     *
     * @return boolean
     */
    public static function isAcceptedHotfix($version, $rel, $ondiskSha)
    {
        $version = self::normVer((string) $version);
        $rel     = ltrim(str_replace('\\', '/', (string) $rel), '/');

        foreach (self::table() as $fix) {
            if ($fix['path'] !== $rel || !isset($fix['versions'][$version])) {
                continue;
            }

            if ((string) $ondiskSha === $fix['versions'][$version]['fixed']) {
                return true;
            }
        }

        return false;
    }

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

    private static function normVer($v)
    {
        if (preg_match('~^(\d+\.\d+\.\d+)~', (string) $v, $m)) {
            return $m[1];
        }

        return (string) $v;
    }

    private static function stateDir()
    {
        $base = function_exists('htp_state_dir') ? (string) htp_state_dir() : '';

        if ($base === '') {
            return '';
        }

        $dir = rtrim(str_replace('\\', '/', $base), '/') . '/' . self::STATE_SUBDIR;

        if (!@is_dir($dir) && !@mkdir($dir, 0755, true) && !@is_dir($dir)) {
            return '';
        }

        return $dir;
    }

    private static function safeName($s)
    {
        return preg_replace('~[^A-Za-z0-9._-]~', '_', (string) $s);
    }

    /**
     * Atomar schreiben und Rechte der Originaldatei erhalten. temp-Datei im selben Ordner,
     * dann rename() (auf einem Dateisystem atomar). Fehlschlag -> false (Original bleibt).
     *
     * @return boolean
     */
    private static function atomicWrite($abs, $data)
    {
        $perm = @fileperms($abs);
        $tmp  = $abs . '.htpnew' . substr(sha1(uniqid('', true)), 0, 8);

        if (@file_put_contents($tmp, $data, LOCK_EX) !== strlen($data)) {
            @unlink($tmp);

            return false;
        }

        if ($perm !== false) {
            @chmod($tmp, $perm & 0777);
        }

        if (!@rename($tmp, $abs)) {
            // Fallback: direkt schreiben (manche Hoster erlauben rename-over nicht).
            $ok = (@file_put_contents($abs, $data, LOCK_EX) === strlen($data));
            @unlink($tmp);

            return $ok;
        }

        return true;
    }
}
