<?php

/**
 * HTProtect Rescue-Endpunkt (2.6.6) - CRASH-FEST, OHNE Joomla-Boot.
 *
 * Zweck: die Fehleranzeige setzen (debug.set) und das/die Backward-Compatibility-Plugin(e)
 * reaktivieren (bc.enable) - AUCH wenn Joomla fatal crasht (Whitescreen). Genau dann, wenn man
 * es am dringendsten braucht, laeuft der normale, Joomla-gebundene Befehlsweg (com_ajax-Poll)
 * NICHT mehr. Dieser Endpunkt ist bewusst self-contained: kein `require` der HTProtect-Engine,
 * kein Joomla-Framework - nur reines PHP.
 *
 * SICHERHEITSMODELL (identisch zum normalen Befehlskanal, link.php::verifyCommand):
 *   pv + Site-Bindung + Typ-Whitelist + expires_at/issued_at(+SKEW) + Nonce-/cmd_id-Ring
 *   + Ed25519-Signatur ueber das kanonische JSON, geprueft gegen den beim Pairing GEPINNTEN
 *   Operator-Schluessel (ws_pub aus dem geschuetzten State). Die Autoritaet liegt AUSSCHLIESSLICH
 *   im Besitz des Operator-Privatschluessels (Browser-Tresor) - der Server/Relay, der diesen
 *   Request nur weiterreicht, kann NICHTS faelschen und keinen fremden Befehl unterschieben.
 *
 * BLAST-RADIUS bewusst winzig: NUR zwei feste Aktionen. Kein eval, kein Include von Nutzer-Pfaden,
 * kein beliebiges Datei-Schreiben, kein SQL aus Nutzereingaben. error_reporting wird gegen eine
 * feste Allow-Liste per Regex in configuration.php gesetzt; das BC-Plugin per FESTER Query aktiviert.
 * Fail-CLOSED an jeder Stelle. Der Endpunkt gibt IMMER nur ein minimales JSON aus (nie Klartext,
 * nie Config-Inhalte, nie Fehlermeldungen des Servers).
 *
 * BEWUSSTE AUSNAHME von der projektweiten `defined('_JEXEC') or die`-Konvention: genau die
 * verhindert einen Aufruf ohne Joomla - hier NOETIG. Die Autorisierung uebernimmt die
 * Ed25519-Signaturpruefung unten (nicht _JEXEC).
 *
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 */

// Dieser Endpunkt darf NIE etwas ausser dem finalen JSON ausgeben.
@ini_set('display_errors', '0');
@error_reporting(0);

define('HTP_R_PROTO', 'link-proto/1');
define('HTP_R_SKEW', 120);            // s Uhr-Toleranz (issued_at in der Zukunft)
define('HTP_R_RING', 256);            // Nonce-/cmd_id-Ringgroesse
define('HTP_R_RATE_MIN', 3);          // s Mindestabstand zwischen Rescue-Calls (grobe DoS-Bremse)
define('HTP_R_ARM', 1800);            // 30 Min Auto-Revert-Frist fuer sichtbare Fehleranzeige
define('HTP_R_MAXBODY', 65536);       // max. Request-Body

/**
 * Minimale JSON-Antwort + sofortiges Ende. Gibt NIE sensible Inhalte preis.
 */
function htp_r_out($ok, $code)
{
    if (!headers_sent()) {
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store, no-cache, must-revalidate');
        header('X-Robots-Tag: noindex, nofollow');
        header('X-Content-Type-Options: nosniff');
    }

    echo json_encode(array('ok' => (bool) $ok, 'code' => (string) $code));
    exit;
}

/**
 * Kanonisches JSON - BYTE-IDENTISCH zu link.php::canonical / canonical.js: Objekt-Schluessel
 * rekursiv sortiert, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE. Damit stimmt die hier
 * gepruefte Signatur exakt mit der im Browser erzeugten ueberein.
 */
function htp_r_canonical($x)
{
    $flags = 0;

    if (defined('JSON_UNESCAPED_SLASHES')) {
        $flags |= JSON_UNESCAPED_SLASHES;
    }

    if (defined('JSON_UNESCAPED_UNICODE')) {
        $flags |= JSON_UNESCAPED_UNICODE;
    }

    return json_encode(htp_r_canon_norm($x), $flags);
}

function htp_r_canon_norm($x)
{
    if (!is_array($x)) {
        return $x;
    }

    $keys   = array_keys($x);
    $isList = ($keys === range(0, count($x) - 1));

    if ($isList) {
        $out = array();

        foreach ($x as $v) {
            $out[] = htp_r_canon_norm($v);
        }

        return $out;
    }

    ksort($x);
    $out = array();

    foreach ($x as $k => $v) {
        $out[$k] = htp_r_canon_norm($v);
    }

    return $out;
}

// ── Pfade (relativ zu dieser Datei) ─────────────────────────────────────────
// Installiert unter {webBase}/plugins/system/htprotectwatch/rescue.php.
// KLASSISCH ist {webBase} == App-Root: dort liegen configuration.php + der Datenordner.
// PUBLIC-FOLDER-MODUS (Joomla 5.1+): dann liegt rescue.php unter public/plugins/... -> {webBase}
// ist der public/-Webroot, waehrend configuration.php UND der HTProtect-Datenordner EINE Ebene
// DARUEBER liegen (App-Root oberhalb des Webroots). Den echten App-Root OHNE Joomla-Boot finden:
// dort, wo configuration.php liegt. Klassisch = $webBase (unveraendert), PF = dirname($webBase).
$webBase    = dirname(__DIR__, 3); // plugins/system/htprotectwatch -> {webBase}
$root       = @is_file($webBase . '/configuration.php') ? $webBase : dirname($webBase);
$dataDir    = $root . '/administrator/components/com_htprotect/data';
$stateFile  = $dataDir . '/htprotect_link.json';
$configFile = $root . '/configuration.php';

$nowT = time();

// Selbstheilender Auto-Revert (CRASH-FEST): ist die 30-Min-Frist fuer eine sichtbare Fehleranzeige
// abgelaufen, stellt dieser Endpunkt seine EIGENE Aenderung selbst wieder auf 'none' - OHNE Joomla/
// Engine, bei JEDEM Aufruf (auch abgelehnten). So greift das Netz auch dann, wenn der Waechter
// (Joomla-System-Plugin) mangels Boot nie laeuft. Nur in die SICHERE Richtung (none) und nur nach
// Fristablauf -> unauthentifiziert unkritisch (schaltet die Fehleranzeige hoechstens frueher AUS).
$untilFile = $dataDir . '/htprotect_errdbg.until';
if (@is_file($untilFile)) {
    $u = (int) @file_get_contents($untilFile);
    if ($u > 0 && $nowT >= $u) {
        htp_r_set_error_reporting($configFile, 'none', $dataDir);
        @unlink($untilFile);
    }
}

// Nur POST (der Server proxyt den signierten Befehl im Body).
if (!isset($_SERVER['REQUEST_METHOD']) || strtoupper((string) $_SERVER['REQUEST_METHOD']) !== 'POST') {
    htp_r_out(false, 'method');
}

// DoS-Bremse PRO IP (nicht global): eine globale Bremse koennte ein Fremd-Flood dauernd frisch
// halten und den echten Operator-Push (kommt vom Dashboard-Server, eigene IP) aussperren. Kleine,
// gedeckelte Map {ip-hash: ts}. Die eigentliche Autoritaet bleibt die Signatur.
$rlFile = $dataDir . '/htprotect_rescue.rl';
$ipKey  = substr(hash('sha256', isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '?'), 0, 16);
$rl     = @is_file($rlFile) ? json_decode((string) @file_get_contents($rlFile), true) : array();

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

foreach ($rl as $k => $ts) {
    if ($nowT - (int) $ts >= HTP_R_RATE_MIN * 8) {
        unset($rl[$k]);
    }
}

if (isset($rl[$ipKey]) && ($nowT - (int) $rl[$ipKey]) < HTP_R_RATE_MIN) {
    htp_r_out(false, 'rate');
}

$rl[$ipKey] = $nowT;

if (count($rl) > 128) {
    $rl = array_slice($rl, -128, null, true);
}

if (@is_dir($dataDir)) {
    @file_put_contents($rlFile, json_encode($rl), LOCK_EX);
}

// libsodium ist Pflicht - ohne Verify wird NICHTS ausgefuehrt (fail-closed).
if (!function_exists('sodium_crypto_sign_verify_detached')) {
    htp_r_out(false, 'no_crypto');
}

// Gepinnten Schluessel + Site-IDs aus dem geschuetzten State lesen.
if (!@is_file($stateFile)) {
    htp_r_out(false, 'unpaired');
}

$st = json_decode((string) @file_get_contents($stateFile), true);

if (!is_array($st) || empty($st['ws_pub']) || empty($st['site_id'])) {
    htp_r_out(false, 'unpaired');
}

// Body lesen: {cmd:{...}, sig:"base64"}.
$rawBody = file_get_contents('php://input');

if ($rawBody === false || strlen($rawBody) > HTP_R_MAXBODY) {
    htp_r_out(false, 'bad_body');
}

$in = json_decode($rawBody, true);

// Dateimanager im Rescue (2.7.2): ein voll E2E-versiegelter Befehl kommt als {sealed} (kein Klartext-
// {cmd,sig}). Faellt bei kaputter Site der normale com_ajax-Weg aus, repariert der Operator hierueber.
// Eigener, self-contained Pfad (entsiegeln->verifizieren->HtpFiles->versiegeln); gibt selbst aus + exit.
if (is_array($in) && isset($in['sealed']) && is_string($in['sealed'])) {
    htp_r_handle_files($st, $dataDir, $root, $configFile, (string) $in['sealed'], $nowT);
    htp_r_out(false, 'files_error'); // Sicherheitsnetz - normalerweise unerreichbar (Handler macht exit).
}

if (!is_array($in) || !isset($in['cmd']) || !is_array($in['cmd']) || !isset($in['sig'])) {
    htp_r_out(false, 'bad_body');
}

$cmd = $in['cmd'];
$sig = (string) $in['sig'];

// ── Verifikation (Reihenfolge wie link.php::verifyCommand) ──────────────────
if ((string) (isset($cmd['pv']) ? $cmd['pv'] : '') !== HTP_R_PROTO) {
    htp_r_out(false, 'bad_proto');
}

// Site-Bindung: der signierte cmd.site_id MUSS die eigene Site treffen (opakes public_id ODER
// die numerische Transport-site_id). Verhindert, dass ein fuer Site A signierter Befehl bei
// Site B eingespielt wird.
$cmdSite = (string) (isset($cmd['site_id']) ? $cmd['site_id'] : '');

if ($cmdSite === ''
    || ($cmdSite !== (string) $st['site_id']
        && !(!empty($st['public_id']) && $cmdSite === (string) $st['public_id']))) {
    htp_r_out(false, 'wrong_site');
}

// Typ-Whitelist: hier NUR die zwei Rescue-Aktionen (NICHT die volle caps()-Liste).
$type = (string) (isset($cmd['type']) ? $cmd['type'] : '');

if (!in_array($type, array('debug.set', 'bc.enable', 'emergency.off'), true)) {
    htp_r_out(false, 'unknown_type');
}

$exp = (int) (isset($cmd['expires_at']) ? $cmd['expires_at'] : 0);
$iss = (int) (isset($cmd['issued_at']) ? $cmd['issued_at'] : 0);

if ($exp <= 0 || $nowT > $exp) {
    htp_r_out(false, 'expired');
}

if ($iss > $nowT + HTP_R_SKEW) {
    htp_r_out(false, 'future');
}

// Kanaltrennung + kurzes Replay-Fenster: der Rescue-Weg akzeptiert NUR kurzlebige Befehle. Lang
// gueltige (Queue-)Befehle (TTL bis 1 h) werden hier abgewiesen -> ein fuer den Normalkanal
// signierter Befehl kann nicht ueber rescue.php wiedereingespielt werden (Cross-Channel-Replay zu).
if ($exp - $nowT > 900) {
    htp_r_out(false, 'ttl_too_long');
}

$nonce = (string) (isset($cmd['nonce']) ? $cmd['nonce'] : '');
$cid   = (string) (isset($cmd['cmd_id']) ? $cmd['cmd_id'] : '');

if ($nonce === '') {
    htp_r_out(false, 'replay');
}

if ($cid === '') {
    htp_r_out(false, 'no_id');
}

// Nonce-/cmd_id-Ring (EIGENE Datei, getrennt vom Live-Connector-State -> keine Race mit dem
// laufenden Agenten). Deckelt auf die letzten HTP_R_RING Eintraege.
$ringFile = $dataDir . '/htprotect_rescue_ring.json';
$ring     = @is_file($ringFile) ? json_decode((string) @file_get_contents($ringFile), true) : array();

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

$seenN = (isset($ring['n']) && is_array($ring['n'])) ? $ring['n'] : array();
$seenC = (isset($ring['c']) && is_array($ring['c'])) ? $ring['c'] : array();

if (in_array($nonce, $seenN, true) || in_array($cid, $seenC, true)) {
    htp_r_out(false, 'replay');
}

// Ed25519-Signatur ueber das kanonische JSON, gepinnter Schluessel. Fail-closed.
$sigRaw = base64_decode($sig, true);
$pubRaw = base64_decode((string) $st['ws_pub'], true);

if ($sigRaw === false || $pubRaw === false || strlen($sigRaw) !== 64 || strlen($pubRaw) !== 32) {
    htp_r_out(false, 'bad_key');
}

$verified = false;

try {
    $verified = sodium_crypto_sign_verify_detached($sigRaw, htp_r_canonical($cmd), $pubRaw);
} catch (\Throwable $e) {
    $verified = false;
} catch (\Exception $e) {
    $verified = false;
}

if (!$verified) {
    htp_r_out(false, 'bad_sig');
}

// Nonce/cmd_id VOR der Ausfuehrung verbuchen (kein Doppel bei Zustell-Wiederholung).
$seenN[] = $nonce;
$seenC[] = $cid;

if (count($seenN) > HTP_R_RING) {
    $seenN = array_slice($seenN, -HTP_R_RING);
}

if (count($seenC) > HTP_R_RING) {
    $seenC = array_slice($seenC, -HTP_R_RING);
}

// FAIL-CLOSED: laesst sich der Nonce nicht persistieren (Ordner nicht schreibbar / Platte voll),
// wird NICHT ausgefuehrt - sonst waere der Replay-Schutz still ausgehebelt.
$ringOk = @is_dir($dataDir)
    && (@file_put_contents($ringFile, json_encode(array('n' => $seenN, 'c' => $seenC)), LOCK_EX) !== false);

if (!$ringOk) {
    htp_r_out(false, 'ring_write');
}

// ── Ausfuehrung ─────────────────────────────────────────────────────────────
$params = (isset($cmd['params']) && is_array($cmd['params'])) ? $cmd['params'] : array();

if ($type === 'debug.set') {
    $level   = strtolower((string) (isset($params['level']) ? $params['level'] : ''));
    $allowed = array('none', 'default', 'simple', 'maximum', 'development');

    if (!in_array($level, $allowed, true)) {
        htp_r_out(false, 'bad_level');
    }

    if (!htp_r_set_error_reporting($configFile, $level, $dataDir)) {
        htp_r_out(false, 'write_failed');
    }

    // Auto-Revert-Frist: bei SICHTBAREN Stufen scharf schalten (der Waechter stellt nach 30 Min
    // auf 'none'); bei none/default die Frist loeschen.
    $until = $dataDir . '/htprotect_errdbg.until';

    if (in_array($level, array('simple', 'maximum', 'development'), true)) {
        @file_put_contents($until, (string) ($nowT + HTP_R_ARM), LOCK_EX);
    } elseif (@is_file($until)) {
        @unlink($until);
    }

    htp_r_out(true, 'debug_set:' . $level);
}

if ($type === 'bc.enable') {
    $res = htp_r_enable_bc($configFile); // 'enabled'|'already'|'absent'|'error'

    if ($res === 'error') {
        htp_r_out(false, 'bc_error');
    }

    htp_r_out(true, 'bc_' . $res);
}

if ($type === 'emergency.off') {
    // Notaus: NUR die Freigabe-Datei im Document-Root anlegen. Der Notfall-.htaccess-Block kennt
    // sie als Ausnahme (RewriteCond %{DOCUMENT_ROOT}/htp-emergency-off !-f) -> die Seite ist sofort
    // wieder offen, ganz OHNE Engine. Die Engine finalisiert danach sauber (Sperre + Block + Datei).
    // BEWUSST minimal + crash-fest: kein Config-/DB-/.htaccess-Schreiben hier. DOCUMENT_ROOT ist
    // exakt der Wert, den Apache in der .htaccess-Ausnahme prueft -> setzende und pruefende Seite
    // stimmen ueberein.
    $dr = isset($_SERVER['DOCUMENT_ROOT']) ? rtrim(str_replace('\\', '/', (string) $_SERVER['DOCUMENT_ROOT']), '/') : '';

    if ($dr === '' || !@is_dir($dr)) {
        htp_r_out(false, 'no_docroot');
    }

    if (@file_put_contents($dr . '/htp-emergency-off', "HTProtect emergency off\n", LOCK_EX) === false) {
        htp_r_out(false, 'write_failed');
    }

    htp_r_out(true, 'emergency_off');
}

htp_r_out(false, 'unknown_type');

// ================= Self-contained Aktions-Helfer =================

/**
 * Joomlas $error_reporting in configuration.php gezielt setzen (Regex-Patch, wie
 * HtpCompat::setJoomlaErrorReporting - hier ohne Engine). $level ist bereits gegen die
 * Allow-Liste geprueft. Schreibt NUR bei exaktem Muster (sonst false, nie Config-Korruption).
 * Backup in den geschuetzten Datenordner (0600). chmod-Tanz fuer schreibgeschuetzte Config.
 */
function htp_r_set_error_reporting($file, $level, $dataDir)
{
    if (!@is_file($file)) {
        return false;
    }

    $src = @file_get_contents($file);

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

    $cnt = 0;
    $new = preg_replace(
        '/(public\s+\$error_reporting\s*=\s*)([\'"])(?:none|default|simple|maximum|development|[0-9]+|-1|E_ALL)\2(\s*;)/i',
        '${1}\'' . $level . '\'${3}',
        $src,
        1,
        $cnt
    );

    if ($new === null || $cnt < 1) {
        return false; // Muster nicht gefunden -> NICHT schreiben
    }

    // Debug-Modus an die Stufe koppeln (self-contained, wie HtpCompat::setJoomlaDebug): NUR
    // 'maximum' = Debug an; jede andere Stufe = aus. Format je Joomla-Generation bewahren
    // (J4-J6 Boolean false/true, J2.5/J3 gequotet '0'/'1'). Fehlt die $debug-Zeile, laesst
    // preg_replace_callback $new unveraendert (kein null) - error_reporting bleibt massgeblich.
    $dbgOn = ($level === 'maximum');
    $new = preg_replace_callback(
        '/(public\s+\$debug\s*=\s*)([\'"]?)(?:true|false|0|1)\2(\s*;)/i',
        function ($m) use ($dbgOn) {
            $q = $m[2];

            return $m[1] . $q . ($q === '' ? ($dbgOn ? 'true' : 'false') : ($dbgOn ? '1' : '0')) . $q . $m[3];
        },
        $new,
        1
    );

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

    if ($new === $src) {
        return true; // schon im Zielzustand
    }

    if (@is_dir($dataDir)) {
        $bak = $dataDir . '/configuration.errdbg.bak.php';

        if (@copy($file, $bak)) {
            @chmod($bak, 0600);
        }
    }

    $ro = !is_writable($file);

    if ($ro) {
        @chmod($file, 0644);
    }

    $ok = @file_put_contents($file, $new) !== false;

    if ($ro) {
        @chmod($file, 0444);
    }

    // configuration.php ist kompilierter PHP-Bytecode: ohne gezielte OPcache-Invalidierung liefert
    // der Server dem unmittelbar folgenden Wake-Tick die ALTE Fassung -> error_reporting/debug im
    // Dashboard erst beim naechsten regulaeren Heartbeat sichtbar (das „mal geht es, mal nicht").
    // Nach erfolgreichem Schreiben gezielt invalidieren; fail-soft, wenn OPcache aus ist.
    if ($ok && function_exists('opcache_invalidate')) {
        @opcache_invalidate($file, true);
    }

    if (function_exists('clearstatcache')) {
        @clearstatcache(true, $file);
    }

    return $ok;
}

/**
 * DB-Zugangsdaten aus configuration.php holen - ueber Joomlas EIGENE JConfig-Klasse (robust gegen
 * Escaping/Sonderzeichen im Passwort; configuration.php ist eine reine Klassendefinition ohne
 * Seiteneffekt). Bei Parse-Fehler (eine moegliche Crash-Ursache) -> null (bc.enable scheitert
 * dann sauber; der normale Befehlsweg heilt es, sobald die Site wieder bootet).
 */
function htp_r_db_creds($configFile)
{
    if (!@is_file($configFile)) {
        return null;
    }

    if (!class_exists('JConfig', false)) {
        try {
            require $configFile;
        } catch (\Throwable $e) {
            return null;
        } catch (\Exception $e) {
            return null;
        }
    }

    if (!class_exists('JConfig', false)) {
        return null;
    }

    try {
        $c = new JConfig();
    } catch (\Throwable $e) {
        return null;
    } catch (\Exception $e) {
        return null;
    }

    return array(
        'host'     => isset($c->host) ? (string) $c->host : 'localhost',
        'user'     => isset($c->user) ? (string) $c->user : '',
        'password' => isset($c->password) ? (string) $c->password : '',
        'db'       => isset($c->db) ? (string) $c->db : '',
        'dbprefix' => isset($c->dbprefix) ? (string) $c->dbprefix : '',
        'dbtype'   => isset($c->dbtype) ? (string) $c->dbtype : 'mysqli',
    );
}

/**
 * BC-Plugin(e) reaktivieren - self-contained ueber mysqli (kein Joomla-DB-Layer). FESTE Query,
 * keine Nutzereingabe. Rueckgabe: 'enabled'|'already'|'absent'|'error'. Nur MySQL/MariaDB
 * (die grosse Mehrheit); andere Treiber -> 'error' (der normale Weg heilt es beim naechsten Tick).
 */
function htp_r_enable_bc($configFile)
{
    $cfg = htp_r_db_creds($configFile);

    if (!$cfg || $cfg['db'] === '' || strpos(strtolower($cfg['dbtype']), 'mysql') === false) {
        return 'error';
    }

    if (!function_exists('mysqli_init')) {
        return 'error';
    }

    // Joomla-Host kann "host:port" ODER "host:/pfad/zum/socket" sein.
    $host   = $cfg['host'];
    $port   = 0;
    $socket = null;

    if (strpos($host, ':') !== false) {
        list($host, $tail) = explode(':', $host, 2);

        if ($tail !== '' && ctype_digit($tail)) {
            $port = (int) $tail;
        } elseif ($tail !== '') {
            $socket = $tail;
        }
    }

    $mysqli = @mysqli_init();

    if (!$mysqli) {
        return 'error';
    }

    @mysqli_options($mysqli, MYSQLI_OPT_CONNECT_TIMEOUT, 5);

    $connected = false;

    try {
        $connected = @mysqli_real_connect($mysqli, $host, $cfg['user'], $cfg['password'], $cfg['db'], $port, $socket);
    } catch (\Throwable $e) {
        $connected = false;
    } catch (\Exception $e) {
        $connected = false;
    }

    if (!$connected) {
        return 'error';
    }

    // Tabellenpraefix streng saeubern (nur [A-Za-z0-9_]) - er stammt aus configuration.php, aber
    // wir lassen keinerlei Sonderzeichen in den Bezeichner.
    $prefix = preg_replace('/[^A-Za-z0-9_]/', '', $cfg['dbprefix']);
    $table  = '`' . $prefix . 'extensions`';

    $result = 'error';

    // Erst Bestand pruefen (absent/already), dann feste UPDATE-Query.
    $sel = @mysqli_query(
        $mysqli,
        'SELECT COUNT(*) AS n, SUM(`enabled` = 0) AS off FROM ' . $table
            . " WHERE `type`='plugin' AND `folder`='behaviour' AND `element` IN ('compat','compat6')"
    );

    if ($sel && ($row = @mysqli_fetch_assoc($sel))) {
        $n   = (int) $row['n'];
        $off = (int) $row['off'];

        if ($n === 0) {
            $result = 'absent';
        } elseif ($off === 0) {
            $result = 'already';
        } else {
            $upd = @mysqli_query(
                $mysqli,
                'UPDATE ' . $table . " SET `enabled`=1"
                    . " WHERE `type`='plugin' AND `folder`='behaviour' AND `element` IN ('compat','compat6')"
            );
            $result = $upd ? 'enabled' : 'error';
        }
    }

    @mysqli_close($mysqli);

    return $result;
}

// ============================================================================
//  Dateimanager im Rescue (2.7.2) - Repair-Subset, self-contained, OHNE Joomla/DB.
//  Kommt der normale (Joomla-gebundene) com_ajax-Weg bei einer kaputten Site nicht mehr durch,
//  repariert der Operator hierueber Dateien (z. B. configuration.php / DB-Passwort). Voll E2E-
//  versiegelt (identisch zum Normalweg): der Server sieht nur Chiffrat. Die Sicherheit (Jail/Deny/
//  Snapshot) liegt in HtpFiles; der Malware-Scan entfaellt hier bewusst (kein Scanner ohne Engine-
//  Boot) - dokumentierter Kompromiss zugunsten der Reparierbarkeit. Nur bei Freischaltung aktiv.
// ============================================================================

/** Ist der Dateimanager fuer diese Site freigeschaltet? (dateibasiert, DB-unabhaengig - wie HtpFiles). */
function htp_r_files_enabled($dataDir)
{
    return @is_file($dataDir . '/htprotect_files.on');
}

/** Umgebungs-Funktionen + HtpStorage-Shim bereitstellen (einmalig), damit HtpFiles standalone laeuft. */
function htp_r_provide_env($root, $dataDir)
{
    if (!defined('HTP_R_FS_ROOT')) {
        define('HTP_R_FS_ROOT', $root);
        define('HTP_R_FS_DATA', $dataDir);
    }

    if (!function_exists('htp_root')) {
        function htp_root()
        {
            return HTP_R_FS_ROOT;
        }
    }

    if (!function_exists('htp_docroot')) {
        function htp_docroot()
        {
            return HTP_R_FS_ROOT;
        }
    }

    if (!function_exists('htp_state_dir')) {
        function htp_state_dir()
        {
            return HTP_R_FS_DATA;
        }
    }

    if (!function_exists('htp_state_file')) {
        function htp_state_file($n)
        {
            return HTP_R_FS_DATA . '/' . $n;
        }
    }

    if (!function_exists('htp_tmp_path')) {
        function htp_tmp_path()
        {
            return HTP_R_FS_ROOT . '/tmp';
        }
    }

    if (!class_exists('HtpStorage')) {
        // Minimal-Shim: nur backupDir()+htpChmod() (fuer HtpFiles-Snapshot/chmod). Ohne Malware-Scanner
        // ueberspringt HtpFiles den Scan-vor-Commit im Rescue (fail-open, dokumentiert).
        class HtpStorage
        {
            public static function backupDir()
            {
                return HTP_R_FS_DATA . '/files-undo';
            }

            public static function htpChmod($path, $mode)
            {
                return @chmod($path, $mode);
            }
        }
    }
}

/** Verifiziert einen files.*-Repair-Befehl (Reihenfolge wie HtpLink::verifyFilesCmd). '' = gueltig. */
function htp_r_verify_files_cmd($cmd, $sig, $st, $dataDir, $nowT)
{
    $types = array('files.browse', 'files.read', 'files.write', 'files.mkdir', 'files.delete', 'files.rename', 'files.chmod');

    if (!is_array($cmd)) {
        return 'bad_cmd';
    }

    if ((string) (isset($cmd['pv']) ? $cmd['pv'] : '') !== HTP_R_PROTO) {
        return 'bad_proto';
    }

    $cmdSite = (string) (isset($cmd['site_id']) ? $cmd['site_id'] : '');

    if ($cmdSite === ''
        || ($cmdSite !== (string) $st['site_id']
            && !(!empty($st['public_id']) && $cmdSite === (string) $st['public_id']))) {
        return 'wrong_site';
    }

    if (!in_array((string) (isset($cmd['type']) ? $cmd['type'] : ''), $types, true)) {
        return 'unknown_type';
    }

    $exp = (int) (isset($cmd['expires_at']) ? $cmd['expires_at'] : 0);
    $iss = (int) (isset($cmd['issued_at']) ? $cmd['issued_at'] : 0);

    if ($exp <= 0 || $nowT > $exp) {
        return 'expired';
    }

    if ($exp - $nowT > 300) {
        return 'ttl_too_long';
    }

    if ($iss > $nowT + HTP_R_SKEW) {
        return 'future';
    }

    $nonce = (string) (isset($cmd['nonce']) ? $cmd['nonce'] : '');
    $cid   = (string) (isset($cmd['cmd_id']) ? $cmd['cmd_id'] : '');

    if ($nonce === '' || $cid === '') {
        return 'replay';
    }

    // Eigener Nonce-/cmd_id-Ring (getrennt vom Debug/BC-Rescue-Ring UND vom Live-Connector-State).
    $ringFile = $dataDir . '/htprotect_rescue_files_ring.json';
    $ring     = @is_file($ringFile) ? json_decode((string) @file_get_contents($ringFile), true) : array();

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

    $seenN = (isset($ring['n']) && is_array($ring['n'])) ? $ring['n'] : array();
    $seenC = (isset($ring['c']) && is_array($ring['c'])) ? $ring['c'] : array();

    if (in_array($nonce, $seenN, true) || in_array($cid, $seenC, true)) {
        return 'replay';
    }

    $sigRaw = base64_decode((string) $sig, true);
    $pubRaw = base64_decode((string) $st['ws_pub'], true);

    if ($sigRaw === false || $pubRaw === false || strlen($sigRaw) !== 64 || strlen($pubRaw) !== 32) {
        return 'bad_key';
    }

    $ok = false;

    try {
        $ok = sodium_crypto_sign_verify_detached($sigRaw, htp_r_canonical($cmd), $pubRaw);
    } catch (\Throwable $e) {
        $ok = false;
    } catch (\Exception $e) {
        $ok = false;
    }

    if (!$ok) {
        return 'bad_sig';
    }

    // Nonce/cmd_id VOR der Ausfuehrung verbuchen; fail-closed, wenn nicht persistierbar.
    $seenN[] = $nonce;
    $seenC[] = $cid;

    if (count($seenN) > HTP_R_RING) {
        $seenN = array_slice($seenN, -HTP_R_RING);
    }

    if (count($seenC) > HTP_R_RING) {
        $seenC = array_slice($seenC, -HTP_R_RING);
    }

    if (!@is_dir($dataDir)
        || @file_put_contents($ringFile, json_encode(array('n' => $seenN, 'c' => $seenC)), LOCK_EX) === false) {
        return 'ring_write';
    }

    return '';
}

/** files.* (Repair-Subset) -> HtpFiles. Inhalt reist base64 (binaersicher). */
function htp_r_dispatch_files($type, $params)
{
    $path = isset($params['path']) ? (string) $params['path'] : '';

    switch ($type) {
        case 'files.browse':
            return HtpFiles::browse($path, isset($params['sort']) ? (string) $params['sort'] : 'folders', isset($params['offset']) ? (int) $params['offset'] : 0);

        case 'files.read':
            $rd = HtpFiles::read($path, isset($params['off']) ? (int) $params['off'] : 0, isset($params['len']) ? (int) $params['len'] : 0);

            if (!empty($rd['ok']) && isset($rd['content'])) {
                $rd['content_b64'] = base64_encode((string) $rd['content']);
                unset($rd['content']);
            }

            return $rd;

        case 'files.write':
            return HtpFiles::write($path, (string) base64_decode((string) (isset($params['content']) ? $params['content'] : '')), (string) (isset($params['expect_md5']) ? $params['expect_md5'] : ''), !empty($params['confirm']));

        case 'files.mkdir':
            return HtpFiles::mkdir($path);

        case 'files.delete':
            return HtpFiles::delete($path, !empty($params['confirm']));

        case 'files.rename':
            return HtpFiles::rename($path, (string) (isset($params['dest']) ? $params['dest'] : ''));

        case 'files.chmod':
            return HtpFiles::chmod($path, (string) (isset($params['mode']) ? $params['mode'] : ''));
    }

    return array('ok' => false, 'code' => 'unknown_type');
}

/** Operator->Site Sealed-Box oeffnen (byte-identisch zu HtpLink::openSealedForSite). Klartext|null. */
function htp_r_open_sealed($st, $blobB64)
{
    try {
        if (!function_exists('sodium_crypto_sign_ed25519_sk_to_curve25519')
            || !function_exists('sodium_crypto_aead_xchacha20poly1305_ietf_decrypt')) {
            return null;
        }

        $sk = base64_decode((string) (isset($st['site_priv']) ? $st['site_priv'] : ''), true);

        if ($sk === false || strlen($sk) !== 64) {
            return null;
        }

        $xPriv = sodium_crypto_sign_ed25519_sk_to_curve25519($sk);
        $xPub  = sodium_crypto_scalarmult_base($xPriv);
        $blob  = base64_decode((string) $blobB64, true);

        if ($blob === false || strlen($blob) < 32 + 24 + 16) {
            return null;
        }

        $eph    = substr($blob, 0, 32);
        $nonce  = substr($blob, 32, 24);
        $ct     = substr($blob, 56);
        $shared = sodium_crypto_scalarmult($xPriv, $eph);
        $key    = substr(hash('sha512', $shared . $eph . $xPub, true), 0, 32);
        $pt     = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($ct, '', $nonce, $key);

        return ($pt === false) ? null : $pt;
    } catch (\Throwable $e) {
        return null;
    } catch (\Exception $e) {
        return null;
    }
}

/** Site->Operator versiegeln (byte-identisch zu HtpLink::sealToOperator). base64|''. */
function htp_r_seal_operator($st, $plaintext)
{
    try {
        if (!function_exists('sodium_crypto_sign_ed25519_pk_to_curve25519')
            || !function_exists('sodium_crypto_aead_xchacha20poly1305_ietf_encrypt')) {
            return '';
        }

        $edPub = base64_decode((string) (isset($st['ws_pub']) ? $st['ws_pub'] : ''), true);

        if ($edPub === false || strlen($edPub) !== 32) {
            return '';
        }

        $xPub    = sodium_crypto_sign_ed25519_pk_to_curve25519($edPub);
        $ephPriv = random_bytes(32);
        $ephPub  = sodium_crypto_scalarmult_base($ephPriv);
        $shared  = sodium_crypto_scalarmult($ephPriv, $xPub);
        $key     = substr(hash('sha512', $shared . $ephPub . $xPub, true), 0, 32);
        $nonce   = random_bytes(24);
        $ct      = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, '', $nonce, $key);

        return base64_encode($ephPub . $nonce . $ct);
    } catch (\Throwable $e) {
        return '';
    } catch (\Exception $e) {
        return '';
    }
}

/** Orchestriert eine Dateimanager-Rescue-Anfrage: entsiegeln -> verifizieren -> HtpFiles -> versiegeln. */
function htp_r_handle_files($st, $dataDir, $root, $configFile, $sealedB64, $nowT)
{
    if (!htp_r_files_enabled($dataDir)) {
        htp_r_out(false, 'files_off');
    }

    $plain = htp_r_open_sealed($st, $sealedB64);

    if ($plain === null) {
        htp_r_out(false, 'unseal_failed');
    }

    $env = json_decode((string) $plain, true);

    if (!is_array($env) || !isset($env['cmd']) || !is_array($env['cmd']) || !isset($env['sig'])) {
        htp_r_out(false, 'bad_env');
    }

    $why = htp_r_verify_files_cmd($env['cmd'], (string) $env['sig'], $st, $dataDir, $nowT);

    if ($why !== '') {
        htp_r_out(false, $why);
    }

    // HtpFiles standalone laden (files.php erwartet _JEXEC; die Autoritaet ist die Signatur oben).
    htp_r_provide_env($root, $dataDir);

    if (!defined('_JEXEC')) {
        define('_JEXEC', 1);
    }

    $filesPhp = $root . '/administrator/components/com_htprotect/core/files.php';

    if (!@is_file($filesPhp)) {
        htp_r_out(false, 'no_engine');
    }

    require_once $filesPhp;

    if (!class_exists('HtpFiles') || !HtpFiles::isEnabled()) {
        htp_r_out(false, 'no_engine');
    }

    $cmd    = $env['cmd'];
    $params = (isset($cmd['params']) && is_array($cmd['params'])) ? $cmd['params'] : array();
    $res    = htp_r_dispatch_files((string) $cmd['type'], $params);

    $sealed = htp_r_seal_operator($st, (string) json_encode($res));

    if ($sealed === '') {
        htp_r_out(false, 'seal_failed');
    }

    if (!headers_sent()) {
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store, no-cache, must-revalidate');
        header('X-Robots-Tag: noindex, nofollow');
        header('X-Content-Type-Options: nosniff');
    }

    echo json_encode(array('ok' => true, 'sealed' => $sealed));
    exit;
}
