<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * Ueberwachung privilegierter Konten (Super User): erkennt NEUE Super-User
 * sowie geaenderte Passwoerter/E-Mails bestehender Super-User gegen eine
 * gemerkte Baseline. Speichert vom Passwort nur den sha1 des Hashes, nie den
 * Hash selbst, und nie Klartext.
 *
 * Admin-Schutz (2.6.9): Zusaetzlich zur reinen Meldung kann der Waechter ein
 * technisch eindeutig EINGESCHLEUSTES Super-User-Konto (an Joomla vorbei per
 * Direkt-INSERT angelegt = leeres/0000-00-00-Anlage-Datum, ODER bekannte
 * Kampagnen-Signatur) automatisch STILLLEGEN (block=1 + aus den Super-User-
 * Gruppen entfernen) und einen an Joomla vorbei ausgesperrten EIGENTUEMER
 * wiederherstellen. Streng abgesichert gegen Aussperren: nie ein baseline-
 * akzeptiertes/whitelisted/vom Betreiber ueber Joomla selbst geaendertes Konto,
 * nie der Ur-Admin (id 1), nie der letzte gute aktive Super-User; alles
 * reversibel (sperren statt loeschen) mit Restore-Journal. Rein rechte-/DB-
 * basiert (keine Malware-Signaturen). Default: Schutz an, Modus 'contain'
 * (auto-stilllegen); 'observe' = nur melden.
 */

defined('_JEXEC') or die;

class HtpAccounts
{
    /**
     * Baseline initialisieren (falls leer) ODER aktuelle Abweichungen liefern.
     * Vom Hintergrund-Plugin UND von der Uebersicht aufgerufen. Beim allerersten
     * Mal wird der Ist-Zustand still als Baseline gemerkt (kein Alarm) - daher
     * erkennt die Funktion nur Aenderungen NACH der Erstinstallation.
     *
     * @param   array  $cfg  Konfiguration (Referenz; Baseline wird ggf. gesetzt+gespeichert)
     *
     * @return array Liste der Vorfaelle (path/kind/id/user/label)
     */
    public static function evaluate(&$cfg)
    {
        if (empty($cfg['accounts_watch'])) {
            return array();
        }

        $base    = (isset($cfg['account_baseline']) && is_array($cfg['account_baseline'])) ? $cfg['account_baseline'] : array();
        $current = self::snapshot();

        if (empty($base)) {
            // Baseline still aufbauen (nur wenn wir wirklich Daten haben).
            // Feldweise speichern (race-sicher): der Waechter laeuft evtl.
            // parallel zu einem Backend-Speichern (notify_email) - nur das
            // account_baseline schreiben, nie das ganze (evtl. veraltete) $cfg.
            // Klon/Migration leert die Baseline vorher via reconcileSiteIdentity()
            // -> dieser Zweig erfasst dann still den Ist-Zustand der NEUEN Instanz
            // (kein Fehlalarm "Admin-E-Mail geaendert").
            if (!empty($current) && class_exists('HtpConfig')) {
                $cfg['account_baseline'] = $current;
                HtpConfig::saveKeys(array('account_baseline' => $current));
            }

            return array();
        }

        // Joomla erneuert beim Login eines lange inaktiven Super-Users dessen
        // VERALTETEN Passwort-Hash transparent (gleiches Passwort, neuer Hash).
        // Solche benignen Erneuerungen STILL in die Baseline uebernehmen (nur den
        // Hash-Teil!) -> kein Fehlalarm "Passwort geaendert". Eine ECHTE Aenderung
        // (Hash war bereits aktuell, oder ohne Login) bleibt ein Vorfall; eine
        // zeitgleiche E-Mail-Aenderung wird weiterhin gemeldet (Email bleibt in
        // der Baseline stehen).
        $rehashDirty = false;

        foreach ($current as $id => $cur) {
            $id = (int) $id;

            if (isset($base[$id]) && self::isBenignRehash($base[$id], $cur)) {
                $base[$id]['phash']     = self::f($cur, 'phash');
                $base[$id]['pneeds']    = isset($cur['pneeds']) ? (int) $cur['pneeds'] : 0;
                $base[$id]['lastvisit'] = self::f($cur, 'lastvisit');
                $rehashDirty = true;
            }
        }

        if ($rehashDirty && class_exists('HtpConfig')) {
            $cfg['account_baseline'] = $base;
            HtpConfig::saveKeys(array('account_baseline' => $base));
        }

        return self::diff($base, $current);
    }

    /**
     * Zentraler Site-Identitaets-Waechter: erkennt anhand des Fingerabdrucks
     * (Pfad + DB), ob HTProtect auf einer ANDEREN Instanz laeuft als zuletzt - also
     * auf einem frisch provisionierten Template/Klon oder nach einer Migration. In
     * dem Fall werden die KLON-EMPFINDLICHEN, pro-Site-eigenen Werte still verworfen,
     * die sonst aus dem Template mitwandern und Schaden anrichten:
     *   - unsub_base       Mail-Abmeldelink-Basis (sonst Template-URL -> falscher Host)
     *   - unsub_secret     HMAC-Secret (sonst auf allen Klonen identisch -> Tokens
     *                      quer-gueltig; ein Empfaenger koennte das Template beeinflussen)
     *   - account_baseline Konten-Baseline der anderen Instanz (sonst Fehlalarm
     *                      "Admin-E-Mail/Passwort geaendert")
     *   - seo_baseline / seo_overview_finding / seo_overview_ts  SEO-Waechter-
     *                      Laufzeitwerte der anderen Instanz (Klon baut Basislinie
     *                      und Befund aus seinem EIGENEN Live-Output neu auf)
     * Die geleerten Werte erzeugen/erfassen sich danach pro Site neu (Secret lazy
     * beim naechsten Mailversand, Basis beim naechsten Backend-Login, Baseline beim
     * naechsten evaluate()-Lauf). Greift NUR im Moment des Wechsels.
     *
     * Reine In-Memory-Pruefung (ein sha1) - schreibt nur im Wechsel- bzw. einmaligen
     * Erstfall. Muss VOR evaluate()/Mailversand laufen; angestossen vom gedrosselten
     * Hintergrund-Tick und vom Backend-Aufruf, nie im Frontend-Hot-Path.
     *
     * @param   array  $cfg  Konfiguration (Referenz; ggf. geaendert + gespeichert)
     *
     * @return string  Durchgefuehrte Aktion: skip|init|same|changed
     */
    public static function reconcileSiteIdentity(&$cfg)
    {
        if (!is_array($cfg)) {
            return 'skip';
        }

        $fp     = self::siteFingerprint();
        $stored = isset($cfg['site_fp']) ? (string) $cfg['site_fp'] : '';
        $action = self::siteAction($stored, $fp);

        if ($action === 'changed') {
            $cfg = self::applyCloneReset($cfg, $fp);

            if (class_exists('HtpConfig')) {
                HtpConfig::saveKeys(array(
                    'unsub_base'           => '',
                    'unsub_secret'         => '',
                    'account_baseline'     => array(),
                    'account_contained'    => array(),
                    'account_sanctioned'   => array(),
                    'seo_baseline'         => array(),
                    'seo_overview_finding' => array(),
                    'seo_overview_ts'      => 0,
                    'site_fp'              => (string) $fp,
                ));
            }
        } elseif ($action === 'init') {
            // Erstlauf bzw. erster Lauf nach Update: Fingerabdruck nur merken,
            // sonst NICHTS aendern (sonst wuerde ein gueltiges Secret rotiert und
            // offene Abmeldelinks brechen). Nur ein SPAETERER Wechsel loest Reset aus.
            $cfg['site_fp'] = (string) $fp;

            if (class_exists('HtpConfig')) {
                HtpConfig::saveKeys(array('site_fp' => (string) $fp));
            }
        }

        return $action;
    }

    /**
     * Reine Entscheidung (testbar), wie der Site-Identitaets-Waechter zu verfahren
     * hat:
     *   skip     kein Fingerabdruck ermittelbar                -> nichts tun (konservativ)
     *   init     noch kein Fingerabdruck gespeichert (Erst/Upd) -> nur merken
     *   changed  Fingerabdruck weicht ab (Klon/Migration)       -> Klon-Werte verwerfen
     *   same     Fingerabdruck passt                            -> nichts tun
     *
     * @param   string  $storedFp  Gespeicherter Fingerabdruck
     * @param   string  $fp        Aktueller Fingerabdruck ('' = nicht ermittelbar)
     *
     * @return string  skip|init|same|changed
     */
    public static function siteAction($storedFp, $fp)
    {
        if ((string) $fp === '') {
            return 'skip';
        }

        if ((string) $storedFp === '') {
            return 'init';
        }

        return ((string) $storedFp === (string) $fp) ? 'same' : 'changed';
    }

    /**
     * Reine Transformation (testbar): klon-empfindliche, pro-Site-eigene Werte aus
     * $cfg verwerfen und den neuen Fingerabdruck setzen. Alle uebrigen Schluessel
     * bleiben unangetastet.
     *
     * @param   array   $cfg  Konfiguration
     * @param   string  $fp   Neuer Site-Fingerabdruck
     *
     * @return array  Bereinigte Konfiguration
     */
    public static function applyCloneReset($cfg, $fp)
    {
        if (!is_array($cfg)) {
            $cfg = array();
        }

        $cfg['unsub_base']           = '';
        $cfg['unsub_secret']         = '';
        $cfg['account_baseline']     = array();
        // Admin-Schutz-Journal + Sanktions-Marker gehoeren zur alten Instanz -> verwerfen
        // (sonst wuerde der Klon fremde „stillgelegte" Konten wiederherstellen wollen).
        $cfg['account_contained']    = array();
        $cfg['account_sanctioned']   = array();
        // SEO-Waechter-Laufzeitwerte gehoeren zur alten Instanz -> verwerfen, der
        // Klon baut Basislinie/Befund aus seinem EIGENEN Live-Output neu auf.
        $cfg['seo_baseline']         = array();
        $cfg['seo_overview_finding'] = array();
        $cfg['seo_overview_ts']      = 0;
        $cfg['site_fp']              = (string) $fp;

        return $cfg;
    }

    /**
     * Fingerabdruck der Site-Identitaet (absoluter App-Pfad + Datenbank-Name/-Host).
     * Aendert sich beim Provisionieren/Umkopieren eines Templates oder Klons (neuer
     * Account-Pfad, eigene Datenbank pro Kunde), bleibt im Normalbetrieb aber
     * konstant. Damit erkennt reconcileSiteIdentity() eine aus einer ANDEREN Instanz
     * mitkopierte Baseline / Mail-Link-Basis / HMAC-Secret. Ein Angreifer, der Pfad
     * UND Datenbank wechseln kann, besitzt ohnehin bereits Server-/DB-Kontrolle.
     *
     * @return string  sha1-Hash, oder '' wenn nichts Verlaessliches ermittelbar ist
     */
    public static function siteFingerprint()
    {
        $root = '';

        if (function_exists('htp_root')) {
            $root = (string) htp_root();
        } elseif (defined('JPATH_ROOT')) {
            $root = (string) JPATH_ROOT;
        }

        $root = strtolower(rtrim(str_replace('\\', '/', $root), '/'));

        $db   = '';
        $host = '';
        $app  = function_exists('htp_app') ? htp_app() : null;

        if ($app && method_exists($app, 'get')) {
            $db   = (string) $app->get('db');
            $host = (string) $app->get('host');
        }

        if (($db === '' || $host === '') && class_exists('JFactory') && method_exists('JFactory', 'getConfig')) {
            try {
                $config = JFactory::getConfig();

                if ($config && method_exists($config, 'get')) {
                    if ($db === '') {
                        $db = (string) $config->get('db');
                    }

                    if ($host === '') {
                        $host = (string) $config->get('host');
                    }
                }
            } catch (Exception $e) {
                // ignorieren - ein Teil-Fingerabdruck genuegt
            }
        }

        // Gar nichts Verlaessliches ermittelbar -> kein Fingerabdruck (kein Re-Baseline).
        if ($root === '' && $db === '') {
            return '';
        }

        return sha1($root . '|' . strtolower($db) . '|' . strtolower($host));
    }

    /**
     * Reiner Vergleich Baseline gegen Ist-Zustand (testbar, ohne DB).
     *
     * @param   array  $base     Baseline [id => entry]
     * @param   array  $current  Aktuell [id => entry]
     *
     * @return array Vorfaelle
     */
    public static function diff($base, $current)
    {
        $out = array();

        if (!is_array($base) || !is_array($current)) {
            return $out;
        }

        foreach ($current as $id => $cur) {
            $id = (int) $id;

            if (!isset($base[$id])) {
                // Neuer Super-User: Benutzer + (falls vorhanden) E-Mail anzeigen.
                $out[] = self::event($id, 'acct_new', self::f($cur, 'username'), '', self::f($cur, 'email'), '');
                continue;
            }

            $b = $base[$id];

            if (self::f($cur, 'phash') !== self::f($b, 'phash')) {
                // Wert-Diskriminator = neuer Passwort-Hash (es wird nur der sha1
                // davon im Schluessel verwendet, nie der Hash selbst) -> jede
                // ECHTE Passwortaenderung meldet erneut; kein anzeigbarer Klartext.
                $out[] = self::event($id, 'acct_pw', self::f($cur, 'username'), '', '', self::f($cur, 'phash'));
            }

            if (self::f($cur, 'email') !== self::f($b, 'email')) {
                // alt -> neu darstellbar; Wert-Diskriminator = die neue Adresse.
                $out[] = self::event(
                    $id, 'acct_email', self::f($cur, 'username'),
                    self::f($b, 'email'), self::f($cur, 'email'), self::f($cur, 'email')
                );
            }
        }

        return $out;
    }

    /**
     * Aktuellen Ist-Zustand komplett als Baseline merken (z. B. "Alle uebernehmen").
     *
     * @param   array  $cfg  Konfiguration (Referenz)
     *
     * @return void
     */
    public static function remember(&$cfg)
    {
        $cfg['account_baseline'] = self::snapshot();
    }

    /**
     * Ein einzelnes Konto als bekannt markieren: dessen Baseline-Eintrag auf den
     * aktuellen Stand setzen (bzw. entfernen, falls das Konto nicht mehr
     * privilegiert ist). Loest die offenen Vorfaelle dieses Kontos auf.
     *
     * @param   array    $cfg  Konfiguration (Referenz)
     * @param   integer  $id   Benutzer-ID
     *
     * @return void
     */
    public static function accept(&$cfg, $id)
    {
        $id   = (int) $id;
        $base = (isset($cfg['account_baseline']) && is_array($cfg['account_baseline'])) ? $cfg['account_baseline'] : array();

        if ($id <= 0) {
            return;
        }

        $current = self::snapshot();

        if (isset($current[$id])) {
            $base[$id] = $current[$id];
        } else {
            unset($base[$id]);
        }

        $cfg['account_baseline'] = $base;
    }

    /**
     * Momentaufnahme der privilegierten Konten (Super User). Liefert je Konto
     * id/username/email/sha1(passwort-hash)/block. Ohne DB -> leeres Array.
     *
     * @return array [id => entry]
     */
    public static function snapshot()
    {
        $db = htp_db();

        if (!$db) {
            return array();
        }

        $groups = self::privilegedGroupIds();

        if (empty($groups)) {
            return array();
        }

        // Je Super-User die konkreten privilegierten Gruppen-Zeilen holen (nicht nur DISTINCT user_id):
        // so kennt die Baseline die exakte Gruppen-Mitgliedschaft -> praezises Wiederherstellen und
        // Erkennen einer Entprivilegierung (Baseline hatte Gruppe 8, jetzt keine mehr).
        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName(array('user_id', 'group_id')))
                ->from($db->quoteName('#__user_usergroup_map'))
                ->where($db->quoteName('group_id') . ' IN (' . implode(',', array_map('intval', $groups)) . ')');
            $db->setQuery($q, 0, 4000); // je SU mehrere Zeilen moeglich -> hoeher gedeckelt
            $mapRows = $db->loadAssocList();
        } catch (Exception $e) {
            return array();
        } catch (Throwable $e) {
            return array();
        }

        $userGroups = array();

        if (is_array($mapRows)) {
            foreach ($mapRows as $g) {
                $uid = (int) (isset($g['user_id']) ? $g['user_id'] : 0);
                $gid = (int) (isset($g['group_id']) ? $g['group_id'] : 0);

                if ($uid > 0 && $gid > 0) {
                    if (!isset($userGroups[$uid])) {
                        $userGroups[$uid] = array();
                    }

                    $userGroups[$uid][$gid] = $gid; // set -> automatisch dedupliziert
                }
            }
        }

        $ids = array_keys($userGroups);

        if (empty($ids)) {
            return array();
        }

        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName(array('id', 'username', 'email', 'password', 'block', 'lastvisitDate', 'registerDate')))
                ->from($db->quoteName('#__users'))
                ->where($db->quoteName('id') . ' IN (' . implode(',', array_map('intval', $ids)) . ')');
            $db->setQuery($q, 0, 500);
            $rows = $db->loadAssocList();
        } catch (Exception $e) {
            return array();
        } catch (Throwable $e) {
            return array();
        }

        if (!is_array($rows)) {
            return array();
        }

        $out = array();

        foreach ($rows as $r) {
            $id = (int) (isset($r['id']) ? $r['id'] : 0);

            if ($id <= 0) {
                continue;
            }

            $pw = (string) (isset($r['password']) ? $r['password'] : '');

            $out[$id] = array(
                'id'        => $id,
                'username'  => (string) (isset($r['username']) ? $r['username'] : ''),
                'email'     => (string) (isset($r['email']) ? $r['email'] : ''),
                'phash'     => sha1($pw),
                'block'     => (int) (isset($r['block']) ? $r['block'] : 0),
                // Fuer die Rehash-Erkennung (kein Fehlalarm beim Login eines lange
                // inaktiven Super-Users): letzter Login + ob der Hash veraltet ist
                // (genau dann erneuert Joomla ihn beim Login transparent).
                'lastvisit' => (string) (isset($r['lastvisitDate']) ? $r['lastvisitDate'] : ''),
                'pneeds'    => self::passwordOutdated($pw) ? 1 : 0,
                // Fuer den Admin-Schutz: Anlage-Datum (leer/0000-00-00 = an Joomla vorbei
                // eingeschleust) + die exakten privilegierten Gruppen (praezises Restore).
                'registered' => (string) (isset($r['registerDate']) ? $r['registerDate'] : ''),
                'groups'     => isset($userGroups[$id]) ? array_values($userGroups[$id]) : array(),
            );
        }

        return $out;
    }

    /**
     * Gruppen-IDs mit Super-User-Recht (core.admin am Root-Asset). Robust gegen
     * abweichende Setups; Fallback auf die Joomla-Standardgruppe 8.
     *
     * @return array
     */
    public static function privilegedGroupIds()
    {
        $db = htp_db();

        if (!$db) {
            return array(8);
        }

        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName('rules'))
                ->from($db->quoteName('#__assets'))
                ->where($db->quoteName('id') . ' = 1');
            $db->setQuery($q);
            $ids = self::extractAdminGroupIds(json_decode((string) $db->loadResult(), true));

            return !empty($ids) ? $ids : array(8);
        } catch (Exception $e) {
            return array(8);
        } catch (Throwable $e) {
            return array(8);
        }
    }

    /**
     * Aus den Root-Asset-Rules die Gruppen mit core.admin=1 ziehen (rein/testbar).
     *
     * @param   mixed  $rules  Dekodiertes rules-JSON
     *
     * @return array
     */
    public static function extractAdminGroupIds($rules)
    {
        $out = array();

        if (is_array($rules) && isset($rules['core.admin']) && is_array($rules['core.admin'])) {
            foreach ($rules['core.admin'] as $gid => $allow) {
                if ((int) $allow === 1) {
                    $out[] = (int) $gid;
                }
            }
        }

        return $out;
    }

    /**
     * Ein Super-User-Konto gegen die bekannten Muster der SP-Page-Builder-RCE-
     * Kampagne (task=asset.uploadCustomIcon) klassifizieren. REIN + testbar.
     *
     * Stufen:
     *  - 'certain' (nahezu sicher): E-Mail auf @secure.local - laut Analyse "the
     *    giveaway", keine echte Domain.
     *  - 'review'  (bitte pruefen): Benutzername nach Kampagnen-Schema
     *    (webeditor48, webmaster63, adminbk ...), Anzeigename "Web Editor"/
     *    "Site Helper"/"Admin Backup", oder andere reservierte/Fake-TLD.
     *  - ''        (unauffaellig).
     *
     * Es wird NIE automatisch gehandelt - der Admin bestaetigt oder loescht.
     *
     * @param   string  $username  Login-Name
     * @param   string  $name      Anzeigename
     * @param   string  $email     E-Mail
     *
     * @return array ['tier' => 'certain'|'review'|'', 'reasons' => array]
     */
    public static function classifyAdmin($username, $name, $email)
    {
        $username = strtolower(trim((string) $username));
        $name     = strtolower(trim((string) $name));
        $email    = strtolower(trim((string) $email));

        $reasons = array();
        $certain = false;

        // 1) E-Mail-Domain: @secure.local ist das dokumentierte Kampagnen-Signal.
        if (substr($email, -13) === '@secure.local') {
            $reasons[] = 'email_securelocal';
            $certain   = true;
        } elseif (preg_match('/@[^@\s]+\.(local|localhost|invalid|test|example)$/', $email)) {
            $reasons[] = 'email_reserved';
        }

        // 2) Benutzername nach Kampagnen-Schema: Praefix + Rolle + 1-3 Ziffern
        //    (webeditor48, webmaster63 ...) ODER Praefix + bk/backup (adminbk).
        if (preg_match('/^(web|site|admin|portal|page|content|cms|user)(editor|master|manager|helper|admin|backup)[0-9]{1,3}$/', $username)
            || preg_match('/^(web|site|admin|portal)(bk|backup)$/', $username)) {
            $reasons[] = 'username_pattern';
        }

        // 3) Anzeigename exakt wie beobachtet.
        if (in_array($name, array('web editor', 'site helper', 'admin backup'), true)) {
            $reasons[] = 'name_pattern';
        }

        // 4) Generierte Konten bekannter Massenkompromittierungs-Kampagnen am festen Namensmuster
        //    erkennen - z. B. die Gridbox-Kampagne (Benutzername xtw18387 + Hex, passende E-Mail
        //    xtw18387+<hex>@outlook.com). Solche Konten stammen aus einem Website-Angriff. Ihre
        //    Rechte-Eskalation greift NICHT immer; die fehlgeschlagenen sitzen dann in der
        //    Registrierungsgruppe und werden von suspiciousAdmins GRUPPEN-UNABHAENGIG eingesammelt.
        //    Sehr enges Muster (Praefix + Hex-Form) -> praktisch fehlalarm-frei.
        foreach (self::campaignSignatures() as $sig) {
            if ((isset($sig['user']) && $sig['user'] !== '' && preg_match($sig['user'], $username))
                || (isset($sig['email']) && $sig['email'] !== '' && preg_match($sig['email'], $email))) {
                $reasons[] = isset($sig['reason']) ? $sig['reason'] : 'campaign_generated';
                $certain   = true;
                break;
            }
        }

        if (empty($reasons)) {
            return array('tier' => '', 'reasons' => array());
        }

        return array('tier' => $certain ? 'certain' : 'review', 'reasons' => $reasons);
    }

    /**
     * Bekannte Massenkompromittierungs-Kampagnen mit generierten Konten (Benutzername/E-Mail nach
     * festem Generator). Je Eintrag: id, Grund-Code (fuer die Anzeige), user-/email-Regex (praezise
     * Klassifizierung) und ein DB-LIKE-Praefix (fuer den guenstigen, gruppen-unabhaengigen Vorfilter
     * in suspiciousAdmins). Bewusst erweiterbar - eine neue Kampagne ist EIN weiterer Eintrag. Reine
     * Konten-Indikatoren (Benutzername/E-Mail-Form), kein ausfuehrbarer Code -> AV-neutral.
     *
     * @return array
     */
    public static function campaignSignatures()
    {
        return array(
            // Gridbox (com_gridbox), ab Ende Juli 2026 aktiv ausgenutzt: der Generator legt Konten
            // mit Benutzername "xtw18387" + vier Hex-Zeichen an, dazu die passende plus-Adresse
            // xtw18387+<hex>@outlook.com. (Joomla Security Strike Team; an gekoppelten Sites bestaetigt.)
            array(
                'id'     => 'gridbox_xtw',
                'reason' => 'campaign_gridbox',
                'user'   => '/^xtw18387[0-9a-f]{4}$/',
                'email'  => '/^xtw18387\+[0-9a-f]{4}@/',
                'like'   => 'xtw18387%',
            ),
            // "joomla@test.com" - dokumentierte IoC-Signatur auf kompromittierten Joomla-Sites:
            // der Angreifer legt einen Super-User mit exakt dieser E-Mail an. Reiner Konten-
            // Indikator (feste E-Mail-Adresse, KEIN ausfuehrbarer Code -> AV-neutral), extrem eng
            // -> praktisch fehlalarmfrei; eine (seltene) legitime Nutzung auf einer Test-Instanz
            // ist voll reversibel und per Whitelist/"Bestaetigen" freigebbar.
            array(
                'id'     => 'admin_joomla_test',
                'reason' => 'campaign_joomla_test',
                'user'   => '',
                'email'  => '/^joomla@test\.com$/',
                'like'   => 'joomla@test.com',
            ),
        );
    }

    /**
     * Aktuelle Super-User gegen die SP-Page-Builder-Kampagnenmuster pruefen.
     * Erkennt auch SCHON VOR der HTProtect-Installation untergeschobene Rogue-
     * Admins (anders als die Baseline, die den Ausgangszustand akzeptiert).
     * Bestaetigte (whitelisted) IDs werden uebersprungen.
     *
     * @param   array  $whitelist  Vom Admin bestaetigte Benutzer-IDs
     *
     * @return array Liste der Treffer (id/username/name/email/registered/lastvisit/block/tier/reasons)
     */
    public static function suspiciousAdmins($whitelist = array())
    {
        $db = htp_db();

        if (!$db) {
            return array();
        }

        $wl = array();

        if (is_array($whitelist)) {
            foreach ($whitelist as $w) {
                $wl[(int) $w] = true;
            }
        }

        // Kandidaten-IDs sammeln (dedupliziert). Zwei Quellen:
        //  (a) Konten in privilegierten Gruppen (klassische Rogue-Admin-Pruefung), und
        //  (b) Konten mit dem Namensmuster bekannter Massenkompromittierungs-Kampagnen -
        //      GRUPPEN-UNABHAENGIG, denn deren Rechte-Eskalation greift nicht immer und laesst
        //      Konten in der Registrierungsgruppe zurueck. Die genaue Entscheidung faellt danach
        //      pro Konto in classifyAdmin; der LIKE-Vorfilter darf ruhig etwas breiter greifen.
        $idset = array();

        $groups = self::privilegedGroupIds();

        if (!empty($groups)) {
            try {
                $q = $db->getQuery(true)
                    ->select('DISTINCT ' . $db->quoteName('user_id'))
                    ->from($db->quoteName('#__user_usergroup_map'))
                    ->where($db->quoteName('group_id') . ' IN (' . implode(',', array_map('intval', $groups)) . ')');
                $db->setQuery($q, 0, 500);
                $gids = $db->loadColumn();

                if (is_array($gids)) {
                    foreach ($gids as $gid) {
                        $gid = (int) $gid;

                        if ($gid > 0) {
                            $idset[$gid] = $gid;
                        }
                    }
                }
            } catch (Exception $e) {
            } catch (Throwable $e) {
            }
        }

        // (b) Kampagnen-Vorfilter: guenstige Praefix-LIKE ueber die vorhandenen #__users-Indizes
        //     (Benutzername/E-Mail), gedeckelt. classifyAdmin klassifiziert anschliessend praezise,
        //     sodass ein etwas breiterer LIKE keine Fehltreffer in die Ausgabe laesst.
        $conds = array();

        foreach (self::campaignSignatures() as $sig) {
            if (empty($sig['like'])) {
                continue;
            }

            $like    = $db->quote((string) $sig['like']);
            $conds[] = $db->quoteName('username') . ' LIKE ' . $like;
            $conds[] = $db->quoteName('email') . ' LIKE ' . $like;
        }

        if (!empty($conds)) {
            try {
                $q = $db->getQuery(true)
                    ->select($db->quoteName('id'))
                    ->from($db->quoteName('#__users'))
                    ->where('(' . implode(' OR ', $conds) . ')');
                $db->setQuery($q, 0, 500);
                $cids = $db->loadColumn();

                if (is_array($cids)) {
                    foreach ($cids as $cid) {
                        $cid = (int) $cid;

                        if ($cid > 0) {
                            $idset[$cid] = $cid;
                        }
                    }
                }
            } catch (Exception $e) {
            } catch (Throwable $e) {
            }
        }

        $ids = array_values($idset);

        if (empty($ids)) {
            return array();
        }

        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName(array('id', 'username', 'name', 'email', 'registerDate', 'lastvisitDate', 'block')))
                ->from($db->quoteName('#__users'))
                ->where($db->quoteName('id') . ' IN (' . implode(',', $ids) . ')');
            $db->setQuery($q, 0, 500);
            $rows = $db->loadAssocList();
        } catch (Exception $e) {
            return array();
        } catch (Throwable $e) {
            return array();
        }

        if (!is_array($rows)) {
            return array();
        }

        $hits = array();

        foreach ($rows as $r) {
            $id = (int) (isset($r['id']) ? $r['id'] : 0);

            if ($id <= 0 || isset($wl[$id])) {
                continue;
            }

            $c = self::classifyAdmin(
                isset($r['username']) ? $r['username'] : '',
                isset($r['name']) ? $r['name'] : '',
                isset($r['email']) ? $r['email'] : ''
            );

            if ($c['tier'] === '') {
                continue;
            }

            $hits[] = array(
                'id'         => $id,
                'username'   => (string) (isset($r['username']) ? $r['username'] : ''),
                'name'       => (string) (isset($r['name']) ? $r['name'] : ''),
                'email'      => (string) (isset($r['email']) ? $r['email'] : ''),
                'registered' => (string) (isset($r['registerDate']) ? $r['registerDate'] : ''),
                'lastvisit'  => (string) (isset($r['lastvisitDate']) ? $r['lastvisitDate'] : ''),
                'block'      => (int) (isset($r['block']) ? $r['block'] : 0),
                'tier'       => $c['tier'],
                'reasons'    => $c['reasons'],
            );
        }

        return $hits;
    }

    /**
     * Ein als Rogue-Admin gemeldetes Konto als vertrauenswuerdig bestaetigen
     * (Whitelist per ID) - es wird dann nicht mehr gemeldet. Loescht/aendert
     * das Konto NICHT.
     *
     * @param   array    $cfg  Konfiguration (Referenz)
     * @param   integer  $id   Benutzer-ID
     *
     * @return void
     */
    public static function approveAdmin(&$cfg, $id)
    {
        $id = (int) $id;

        if ($id <= 0) {
            return;
        }

        $wl   = (isset($cfg['admin_whitelist']) && is_array($cfg['admin_whitelist'])) ? $cfg['admin_whitelist'] : array();
        $wl[] = $id;

        $cfg['admin_whitelist'] = array_values(array_unique(array_map('intval', $wl)));
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Admin-Schutz (2.6.9): eingeschleuste Super-User automatisch stilllegen +
    //  ausgesperrte Eigentuemer wiederherstellen. Die EINZIGEN Methoden der Klasse,
    //  die je an #__users / #__user_usergroup_map SCHREIBEN. Streng gegen Aussperren
    //  abgesichert, alles reversibel, rein rechte-/DB-basiert (AV-neutral).
    // ─────────────────────────────────────────────────────────────────────────

    /** Karenzfenster (Sek.): so lange setzt eine ueber Joomla sanktionierte Aenderung
     *  eines Kontos den Schutz aus (der Betreiber hat gerade selbst gehandelt). Zusaetzliches
     *  Netz zur dauerhaften Baseline-Uebernahme (accept) - nicht das alleinige Mittel. */
    const SANCTION_GRACE = 900;

    /** Obergrenze des Restore-Journals (account_contained): schuetzt die Config-Spalte vor
     *  unbegrenztem Wachstum bei einer anhaltenden Rogue-Admin-Kampagne (Schreib-DoS). */
    const JOURNAL_MAX = 500;

    /** Ist ein Anlage-Datum ungueltig/leer = an Joomla vorbei per Direkt-INSERT
     *  angelegt? Ueber die Oberflaeche laesst sich das nicht erzeugen. Rein/testbar. */
    public static function registerDateInvalid($d)
    {
        $d = trim((string) $d);

        if ($d === '' || strpos($d, '0000-00-00') === 0) {
            return true;
        }

        $ts = @strtotime($d);

        return ($ts === false || $ts <= 0);
    }

    /** Einstufung eines Kontos (rein/testbar, keine DB):
     *    'injected_certain' = eindeutig eingeschleust (auto-stilllegbar)
     *    'review'           = auffaellig, aber unsicher (NUR melden, nie auto)
     *    ''                 = unauffaellig / bestaetigt
     *  Whitelist schlaegt alles. */
    public static function intrusionTier($acct, $whitelist = array(), $attackActive = false)
    {
        if (!is_array($acct)) {
            return '';
        }

        $id = (int) (isset($acct['id']) ? $acct['id'] : 0);

        if ($id > 0 && is_array($whitelist) && in_array($id, array_map('intval', $whitelist), true)) {
            return '';
        }

        $c = self::classifyAdmin(
            isset($acct['username']) ? $acct['username'] : '',
            isset($acct['name']) ? $acct['name'] : '',
            isset($acct['email']) ? $acct['email'] : ''
        );

        // Bekannte Kampagnen-Signatur (@secure.local / xtw18387 ...): extrem enge Muster,
        // praktisch fehlalarmfrei -> sicher eingeschleust.
        if ($c['tier'] === 'certain') {
            return 'injected_certain';
        }

        // Leeres/0000-00-00-Anlage-Datum = an Joomla vorbei per Direkt-INSERT angelegt (ueber die
        // Joomla-Oberflaeche NICHT erzeugbar) -> eingeschleust, auto-stilllegbar. Sicher, weil die
        // einzige legitime Quelle (Operator-Script mit Null-Datum) durch registerDate=NOW im Script
        // sowie die Baseline/Whitelist-Ausnahme abgedeckt ist; ein Rest-Fehlalarm (Kunden-Restore/
        // -Import) ist voll reversibel + per Whitelist freigebbar und trifft nie den letzten Admin.
        // ($attackActive wird bewusst NICHT genutzt: die breiten review-Muster .local/.test +
        // Namensschema duerfen nie eine Auto-Sperre ausloesen.)
        if (self::registerDateInvalid(isset($acct['registered']) ? $acct['registered'] : '')) {
            return 'injected_certain';
        }

        return $c['tier']; // 'review' oder ''
    }

    /** Steht fuer $id eine kuerzlich ueber Joomla sanktionierte Aenderung an? (rein/testbar) */
    public static function sanctionedWithinGrace($map, $id, $now, $grace)
    {
        $id = (int) $id;

        if ($id <= 0 || !is_array($map) || !isset($map[$id])) {
            return false;
        }

        $ts = (int) $map[$id];

        return ($ts > 0 && ($now - $ts) <= (int) $grace);
    }

    /** Reine Auswahl der zu containenden Konten (KEINE DB). Wendet ALLE Aussperr-
     *  Invarianten an: nur injected_certain, nicht baseline-akzeptiert, nicht
     *  whitelisted, nicht sanktioniert, nicht id<=1, nicht der eingeloggte Nutzer -
     *  und NUR wenn danach mind. EIN aktiver Super-User bleibt (sonst []).
     *
     *  @return array Liste der zu containenden Konto-Eintraege (aus $current)
     */
    public static function selectContainable($current, $baseline, $whitelist, $sanctioned, $attack, $currentUserId, $now, $grace)
    {
        if (!is_array($current) || empty($current)) {
            return array();
        }

        $baseline      = is_array($baseline) ? $baseline : array();
        $whitelist     = is_array($whitelist) ? $whitelist : array();
        $sanctioned    = is_array($sanctioned) ? $sanctioned : array();
        $currentUserId = (int) $currentUserId;

        $cand = array();

        foreach ($current as $id => $acct) {
            $id = (int) $id;

            if ($id <= 1 || $id === $currentUserId) {
                continue; // Ur-Admin (id 1) + der gerade handelnde Nutzer nie
            }

            if (isset($baseline[$id])) {
                continue; // bei der Erstaufnahme akzeptiert -> nie automatisch anfassen
            }

            if (self::sanctionedWithinGrace($sanctioned, $id, $now, $grace)) {
                continue; // der Betreiber hat es gerade selbst ueber Joomla geaendert
            }

            if (self::intrusionTier($acct, $whitelist, $attack) !== 'injected_certain') {
                continue; // nur technisch eindeutig Eingeschleustes
            }

            $cand[$id] = $acct;
        }

        if (empty($cand)) {
            return array();
        }

        // Aussperr-Invariante: bleibt nach dem Containment mind. EIN aktiver Super-User
        // (block==0), der NICHT containt wird? Sonst NICHTS tun.
        $goodActive = 0;

        foreach ($current as $id => $acct) {
            $id = (int) $id;

            if (isset($cand[$id])) {
                continue;
            }

            if ((int) (isset($acct['block']) ? $acct['block'] : 0) === 0) {
                $goodActive++;
            }
        }

        if ($goodActive < 1) {
            return array(); // wuerde den letzten aktiven Admin nehmen -> abbrechen
        }

        return array_values($cand);
    }

    /** Reiner Wiederherstellungs-Plan (KEINE DB): welche baseline-bestaetigten
     *  Eigentuemer sind an Joomla vorbei gesperrt/entprivilegiert worden (ohne
     *  Sanktion) und muessen zurueck? Liefert je Konto id/groups/username/reason. */
    public static function restorePlan($baseline, $current, $sanctioned, $now, $grace)
    {
        $plan = array();

        if (!is_array($baseline) || empty($baseline)) {
            return $plan;
        }

        $current    = is_array($current) ? $current : array();
        $sanctioned = is_array($sanctioned) ? $sanctioned : array();

        foreach ($baseline as $id => $b) {
            $id = (int) $id;

            if ($id <= 0 || !is_array($b)) {
                continue;
            }

            if (self::sanctionedWithinGrace($sanctioned, $id, $now, $grace)) {
                continue; // der Betreiber hat die Aenderung selbst vorgenommen
            }

            $bBlock  = (int) (isset($b['block']) ? $b['block'] : 0);
            $bGroups = (isset($b['groups']) && is_array($b['groups'])) ? array_values(array_map('intval', $b['groups'])) : array();
            $uname   = (string) (isset($b['username']) ? $b['username'] : '');

            if (!isset($current[$id])) {
                // War Super-User, ist jetzt keiner mehr -> entprivilegiert (an Joomla vorbei).
                $plan[] = array('id' => $id, 'groups' => $bGroups, 'username' => $uname, 'reason' => 'degrouped');
                continue;
            }

            $cBlock = (int) (isset($current[$id]['block']) ? $current[$id]['block'] : 0);

            if ($bBlock === 0 && $cBlock === 1) {
                // War frei, jetzt gesperrt (ohne Sanktion) -> Aussperr-Angriff.
                $plan[] = array('id' => $id, 'groups' => array(), 'username' => $uname, 'reason' => 'blocked');
            }
        }

        return $plan;
    }

    // ── DB-Schreib-Primitiven (fail-CLOSED: false bei DB-Fehler) ──────────────

    private static function userExists($id)
    {
        $id = (int) $id;
        $db = htp_db();

        if ($id <= 0 || !$db) {
            return false;
        }

        try {
            $db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName('#__users') . ' WHERE ' . $db->quoteName('id') . ' = ' . $id);

            return ((int) $db->loadResult()) > 0;
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }
    }

    /** Fail-SAFE Existenzpruefung fuers Journal-Pruning: liefert die Menge noch existierender
     *  Nutzer-IDs als Set (id => true) - ODER null bei DB-Fehler. null = „unbekannt" -> der
     *  Aufrufer verwirft NICHTS (nie einen Journal-Eintrag bei einem DB-Aussetzer loeschen und
     *  damit seinen Wiederherstellungs-Zustand verlieren). Eine einzige IN(...)-Query. */
    private static function existingUserIds($ids)
    {
        if (!is_array($ids) || empty($ids)) {
            return array();
        }

        $clean = array();

        foreach ($ids as $id) {
            $id = (int) $id;

            if ($id > 0) {
                $clean[$id] = $id;
            }
        }

        if (empty($clean)) {
            return array();
        }

        $db = htp_db();

        if (!$db) {
            return null;
        }

        try {
            $db->setQuery('SELECT ' . $db->quoteName('id') . ' FROM ' . $db->quoteName('#__users')
                . ' WHERE ' . $db->quoteName('id') . ' IN (' . implode(',', $clean) . ')');
            $rows = $db->loadColumn();

            if (!is_array($rows)) {
                return null; // unklar (Treiber lieferte kein Array) -> nichts verwerfen
            }

            $set = array();

            foreach ($rows as $r) {
                $set[(int) $r] = true;
            }

            return $set;
        } catch (Exception $e) {
            return null;
        } catch (Throwable $e) {
            return null;
        }
    }

    private static function blockUser($id)
    {
        $id = (int) $id;
        $db = htp_db();

        if ($id <= 1 || !$db) {
            return false; // id 1 (Ur-Admin) + ungueltige nie sperren
        }

        try {
            $db->setQuery('UPDATE ' . $db->quoteName('#__users') . ' SET ' . $db->quoteName('block') . ' = 1 WHERE ' . $db->quoteName('id') . ' = ' . $id);
            $db->execute();

            return true;
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }
    }

    private static function stripPrivilegedGroups($id, $groupIds)
    {
        $id = (int) $id;
        $db = htp_db();

        if ($id <= 1 || !$db || !is_array($groupIds) || empty($groupIds)) {
            return false;
        }

        $gids = array_values(array_unique(array_filter(array_map('intval', $groupIds), function ($g) { return $g > 0; })));

        if (empty($gids)) {
            return false;
        }

        try {
            $db->setQuery('DELETE FROM ' . $db->quoteName('#__user_usergroup_map')
                . ' WHERE ' . $db->quoteName('user_id') . ' = ' . $id
                . ' AND ' . $db->quoteName('group_id') . ' IN (' . implode(',', $gids) . ')');
            $db->execute();

            return true;
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }
    }

    private static function restoreUser($id, $priorBlock, $groupIds)
    {
        $id = (int) $id;
        $db = htp_db();

        // Geloeschtes Konto -> NICHT wiederherstellen (sonst Waisen-Zeilen im group_map).
        if ($id <= 0 || !$db || !self::userExists($id)) {
            return false;
        }

        $ok = true;

        try {
            $db->setQuery('UPDATE ' . $db->quoteName('#__users') . ' SET ' . $db->quoteName('block') . ' = ' . (int) $priorBlock . ' WHERE ' . $db->quoteName('id') . ' = ' . $id);
            $db->execute();
        } catch (Exception $e) {
            $ok = false;
        } catch (Throwable $e) {
            $ok = false;
        }

        // Gruppen wieder herstellen: nur fehlende einfuegen (DB-agnostisch, keine Duplikate).
        if (is_array($groupIds)) {
            foreach ($groupIds as $gid) {
                $gid = (int) $gid;

                if ($gid <= 0) {
                    continue;
                }

                try {
                    $db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName('#__user_usergroup_map')
                        . ' WHERE ' . $db->quoteName('user_id') . ' = ' . $id
                        . ' AND ' . $db->quoteName('group_id') . ' = ' . $gid);

                    if (((int) $db->loadResult()) === 0) {
                        $db->setQuery('INSERT INTO ' . $db->quoteName('#__user_usergroup_map')
                            . ' (' . $db->quoteName('user_id') . ', ' . $db->quoteName('group_id') . ') VALUES (' . $id . ', ' . $gid . ')');
                        $db->execute();
                    }
                } catch (Exception $e) {
                    $ok = false;
                } catch (Throwable $e) {
                    $ok = false;
                }
            }
        }

        return $ok;
    }

    /** Unabhaengige, robuste Zaehlung aktiver (nicht geblockter) Super-User.
     *  -1 = DB unsicher (dann nie sperren). Getrennt von snapshot() (fail-open),
     *  damit die Aussperr-Pruefung sich nicht auf ein leeres fail-open-Ergebnis verlaesst. */
    private static function countActiveSuperUsers()
    {
        $db = htp_db();

        if (!$db) {
            return -1;
        }

        $groups = self::privilegedGroupIds();

        if (empty($groups)) {
            return -1;
        }

        try {
            $q = 'SELECT COUNT(DISTINCT u.' . $db->quoteName('id') . ') FROM ' . $db->quoteName('#__users') . ' AS u'
                . ' INNER JOIN ' . $db->quoteName('#__user_usergroup_map') . ' AS m ON m.' . $db->quoteName('user_id') . ' = u.' . $db->quoteName('id')
                . ' WHERE m.' . $db->quoteName('group_id') . ' IN (' . implode(',', array_map('intval', $groups)) . ')'
                . ' AND u.' . $db->quoteName('block') . ' = 0';
            $db->setQuery($q);
            $n = $db->loadResult();

            return ($n === null) ? -1 : (int) $n;
        } catch (Exception $e) {
            return -1;
        } catch (Throwable $e) {
            return -1;
        }
    }

    /** Der Schreib-Pfad des Admin-Schutzes. Reihenfolge zwingend: (1) RESTORE-FIRST
     *  (ausgesperrte Eigentuemer zuerst zurueckholen, damit ein guter Admin aktiv ist),
     *  (2) dann eingeschleuste Konten stilllegen - Journal IMMER vor jeder Mutation.
     *  Fail-open bei Lesefehlern, fail-closed beim Sperren. Gibt Melde-Ereignisse
     *  (acct_restored/acct_stopped) zurueck, die der Aufrufer in $incidents merged.
     *
     *  @param  array    $cfg            Config (Referenz; Journal via saveKeys)
     *  @param  boolean  $attackActive   Laeuft im selben Lauf ein Angriff (Malware/WAF/rogue)?
     *  @param  integer  $currentUserId  Der gerade eingeloggte Nutzer (nie sperren); 0 im Tick
     *
     *  @return array Ereignis-Liste
     */
    public static function guardTick(&$cfg, $attackActive = false, $currentUserId = 0)
    {
        if (!is_array($cfg) || empty($cfg['admin_guard']) || !class_exists('HtpConfig')) {
            return array();
        }

        $mode = isset($cfg['admin_guard_mode']) ? (string) $cfg['admin_guard_mode'] : 'contain';

        if ($mode !== 'contain') {
            return array(); // 'observe' = nur melden, keine Schreibaktion
        }

        // FRISCH aus dem Store lesen (nicht aus dem evtl. veralteten Tick-$cfg): so sind die ueber
        // Joomla sanktionierten Aenderungen + die Baseline-Uebernahme (accept) aus PARALLELEN
        // Backend-Requests sichtbar -> kein Containment/Restore gegen einen ueberholten Stand.
        $src = HtpConfig::load();

        if (!is_array($src)) {
            $src = $cfg;
        }

        $baseline   = (isset($src['account_baseline']) && is_array($src['account_baseline'])) ? $src['account_baseline'] : array();
        $whitelist  = (isset($src['admin_whitelist']) && is_array($src['admin_whitelist'])) ? $src['admin_whitelist'] : array();
        $sanctioned = (isset($src['account_sanctioned']) && is_array($src['account_sanctioned'])) ? $src['account_sanctioned'] : array();
        $journal    = (isset($src['account_contained']) && is_array($src['account_contained'])) ? $src['account_contained'] : array();

        // Erstlauf (leere Baseline): evaluate() nimmt den Ist-Zustand gerade erst still
        // auf -> hier NICHTS tun. Der Schutz greift erst fuer Aenderungen DANACH.
        if (empty($baseline)) {
            return array();
        }

        $now    = time();
        $events = array();

        // ── FREIGEBEN: vom Betreiber whitelisted-e, zuvor stillgelegte Konten wiederherstellen ──
        // „Freigeben" = das Konto in die admin_whitelist aufnehmen (approveAdmin). HTProtect stellt es
        // dann hier aus dem Journal wieder her (voriger Block + Gruppen) und nimmt es aus dem Journal;
        // selectContainable fasst es kuenftig nicht mehr an (whitelisted). restoreUser ist fail-closed
        // (userExists), ein geloeschtes Konto wird nicht zu Waisen-Zeilen.
        if (!empty($journal) && !empty($whitelist)) {
            $wl           = array_map('intval', $whitelist);
            $journalDirty = false;

            foreach ($journal as $jid => $j) {
                if (!in_array((int) $jid, $wl, true)) {
                    continue;
                }

                $grps = (isset($j['groups']) && is_array($j['groups']) && !empty($j['groups']))
                    ? array_values(array_map('intval', $j['groups']))
                    : self::privilegedGroupIds();

                // NUR bei erfolgreicher Wiederherstellung aus dem Journal nehmen. Bei transientem
                // DB-Fehler den Eintrag STEHEN LASSEN -> naechster Tick versucht erneut. Sonst bliebe
                // ein freigegebenes (whitelisted) Konto dauerhaft gesperrt (Aussperr-Garantie verletzt).
                if (self::restoreUser((int) $jid, (int) (isset($j['block']) ? $j['block'] : 0), $grps)) {
                    $events[]     = self::event((int) $jid, 'acct_restored', (string) (isset($j['username']) ? $j['username'] : ''), '', '', 'release');
                    unset($journal[$jid]);
                    $journalDirty = true;
                }
            }

            if ($journalDirty) {
                HtpConfig::saveKeys(array('account_contained' => $journal));
            }
        }

        // Verlaesslichkeit des DB-Lesens ZUERST pruefen (unabhaengige, werfende Zaehlung). Ein
        // transienter DB-Fehler darf NIE einen Massen-Restore ausloesen: snapshot() ist fail-open
        // und saehe sonst ALLE Baseline-Konten als „entprivilegiert". -1 = unsicher -> gar nichts.
        if (self::countActiveSuperUsers() < 0) {
            $cfg['account_contained'] = $journal;

            return $events;
        }

        // ── 1) RESTORE-FIRST: an Joomla vorbei ausgesperrte Eigentuemer zuerst zurueckholen ──
        $cur = self::snapshot();

        // Fail-closed wie die Containment-Phase: ein leerer/fehlgeschlagener snapshot (fail-open ->
        // []) darf NIE einen Massen-Restore ausloesen (restorePlan saehe sonst JEDES Baseline-Konto
        // als „entprivilegiert"). Die Baseline ist hier garantiert nicht leer -> leeres $cur ist
        // immer verdaechtig. countActiveSuperUsers()>=0 oben schliesst das TOCTOU-Fenster nicht ganz.
        if (empty($cur)) {
            $cfg['account_contained'] = $journal;

            return $events;
        }

        $plan = self::restorePlan($baseline, $cur, $sanctioned, $now, self::SANCTION_GRACE);

        foreach ($plan as $p) {
            $groups = (isset($p['groups']) && is_array($p['groups']) && !empty($p['groups']))
                ? $p['groups']
                : self::privilegedGroupIds(); // alte Baseline ohne 'groups' -> Standard-SU-Gruppe

            if (self::restoreUser((int) $p['id'], 0, $groups)) {
                $events[] = self::event((int) $p['id'], 'acct_restored', (string) $p['username'], '', '', (string) $p['reason']);
            }
        }

        // ── 2) CONTAINMENT: eingeschleuste Konten stilllegen ──
        // Frisch lesen (NACH Restore, damit ein wiederhergestellter Eigentuemer als aktiver Admin
        // zaehlt) + erneute Sicherheits-Zaehlung (mind. 1 aktiver SU muss bleiben).
        $cur = self::snapshot();

        if (empty($cur) || self::countActiveSuperUsers() < 1) {
            $cfg['account_contained'] = $journal;

            return $events; // DB unsicher / keine aktiven SU -> nichts sperren
        }

        $pick = self::selectContainable($cur, $baseline, $whitelist, $sanctioned, (bool) $attackActive, (int) $currentUserId, $now, self::SANCTION_GRACE);

        foreach ($pick as $acct) {
            $id       = (int) $acct['id'];
            $priorBlk = (int) (isset($acct['block']) ? $acct['block'] : 0);
            $grps     = (isset($acct['groups']) && is_array($acct['groups'])) ? array_values(array_map('intval', $acct['groups'])) : array();
            $uname    = (string) (isset($acct['username']) ? $acct['username'] : '');

            // Journal-Deckel: bei erreichter Obergrenze den aeltesten Eintrag verwerfen (nur wenn dies
            // ein NEUES Konto ist) -> verhindert unbegrenztes Wachstum der Config-Spalte (Schreib-DoS).
            if (count($journal) >= self::JOURNAL_MAX && !isset($journal[$id])) {
                $oldestId = null;
                $oldestTs = PHP_INT_MAX;

                foreach ($journal as $jid => $j) {
                    $jts = (int) (isset($j['ts']) ? $j['ts'] : 0);

                    if ($jts < $oldestTs) {
                        $oldestTs = $jts;
                        $oldestId = $jid;
                    }
                }

                if ($oldestId !== null) {
                    unset($journal[$oldestId]);
                }
            }

            // Journal VOR jeder Mutation (Absturz-fest). Laesst es sich NICHT persistieren
            // (saveKeys === false: Config unlesbar / zu gross), dann NICHT sperren - kein Netz,
            // keine Sperre (sonst „contained" ohne Wiederherstellbarkeit). Einen SCHON bestehenden
            // Journal-Eintrag (block/groups/ts) NICHT ueberschreiben -> der echte Vorher-Zustand
            // bleibt fuer ein spaeteres Freigeben erhalten (kein Verlust bei Re-Containment).
            $journal[$id] = array(
                'block'      => (isset($journal[$id]['block']) ? (int) $journal[$id]['block'] : $priorBlk),
                'groups'     => (isset($journal[$id]['groups']) && is_array($journal[$id]['groups']) && !empty($journal[$id]['groups'])) ? array_values(array_map('intval', $journal[$id]['groups'])) : $grps,
                'registered' => (string) (isset($acct['registered']) ? $acct['registered'] : ''),
                'username'   => $uname,
                'ts'         => (isset($journal[$id]['ts']) ? (int) $journal[$id]['ts'] : $now),
            );

            if (HtpConfig::saveKeys(array('account_contained' => $journal)) === false) {
                unset($journal[$id]);
                continue;
            }

            if (!self::blockUser($id)) {
                // Sperre gescheitert -> Journal-Eintrag zuruecknehmen (kein „contained" ohne Wirkung).
                unset($journal[$id]);
                HtpConfig::saveKeys(array('account_contained' => $journal));
                continue;
            }

            self::stripPrivilegedGroups($id, $grps);
        }

        // Meldung + Aufraeumen: acct_stopped fuer JEDES stillgelegte Konto direkt aus dem dauerhaften
        // Journal (Login-Sperren UND Tick-Sperren). Robust statt einer separaten Einweg-Warteschlange:
        // die Anti-Spam-Pipeline (path|kind|vkey) mailt je Konto genau einmal und erinnert erst nach
        // dem Reminder-Intervall - kein Verlust bei fehlendem Empfaenger, kein Race. Ein
        // entprivilegiertes Konto taucht in snapshot()/diff() nie auf.
        // Vorher: Eintraege verwerfen, deren Konto der Betreiber inzwischen GELOESCHT hat (statt es
        // freizugeben) -> erledigt, keine Geister-Erinnerung ueber ein nicht mehr existierendes Konto.
        // Fail-safe: nur verwerfen, wenn die DB definitiv „existiert nicht" liefert; bei DB-Fehler
        // (existingUserIds === null) NICHTS verwerfen und normal weiter erinnern.
        $present   = self::existingUserIds(array_map('intval', array_keys($journal)));
        $prunedAny = false;

        foreach ($journal as $jid => $j) {
            if (is_array($present) && !isset($present[(int) $jid])) {
                unset($journal[$jid]); // Konto existiert nicht mehr -> aus dem Journal, nicht mehr erinnern
                $prunedAny = true;
                continue;
            }

            $events[] = self::event((int) $jid, 'acct_stopped', (string) (isset($j['username']) ? $j['username'] : ''), '', '', 'contained');
        }

        if ($prunedAny) {
            HtpConfig::saveKeys(array('account_contained' => $journal));
        }

        $cfg['account_contained'] = $journal;

        return $events;
    }

    /** Reine Entscheidung (KEINE DB): soll ein sich gerade einloggendes Super-User-Konto SOFORT
     *  stillgelegt werden? Wie selectContainable, aber fuer EIN Konto beim Login. $activeCount =
     *  Anzahl aktiver (nicht geblockter) Super-User INKL. dieses Kontos -> es muss >=2 sein, damit
     *  nach dem Sperren mind. EINER bleibt. $activeCount<0 (DB unsicher) -> nie sperren. Testbar. */
    public static function loginBlockDecision($acct, $baseline, $whitelist, $sanctioned, $activeCount, $now, $grace)
    {
        if (!is_array($acct)) {
            return false;
        }

        $id = (int) (isset($acct['id']) ? $acct['id'] : 0);

        if ($id <= 1) {
            return false; // Ur-Admin (id 1) / ungueltig nie
        }

        if (is_array($baseline) && isset($baseline[$id])) {
            return false; // bei der Erstaufnahme akzeptiert
        }

        if (self::sanctionedWithinGrace($sanctioned, $id, $now, $grace)) {
            return false; // ueber Joomla sanktioniert (Betreiber selbst)
        }

        if (self::intrusionTier($acct, $whitelist, false) !== 'injected_certain') {
            return false; // nur technisch eindeutig Eingeschleustes (Null-Datum/Kampagne)
        }

        // Aussperr-Invariante: nach dem Sperren muss mind. EIN aktiver Super-User bleiben.
        // $activeCount zaehlt inkl. dieses (jetzt eingeloggten) Kontos. <2 -> nicht sperren.
        if ((int) $activeCount < 2) {
            return false;
        }

        return true;
    }

    /** Ein einzelnes Konto als Super-User-Eintrag laden (id/username/email/block/registered/groups)
     *  oder null, wenn es KEIN Super-User ist bzw. die DB nicht sicher lesbar ist (fail-open). */
    private static function loadSuperUser($id)
    {
        $id = (int) $id;
        $db = htp_db();

        if ($id <= 0 || !$db) {
            return null;
        }

        $groups = self::privilegedGroupIds();

        if (empty($groups)) {
            return null;
        }

        try {
            $db->setQuery('SELECT ' . $db->quoteName('group_id') . ' FROM ' . $db->quoteName('#__user_usergroup_map')
                . ' WHERE ' . $db->quoteName('user_id') . ' = ' . $id
                . ' AND ' . $db->quoteName('group_id') . ' IN (' . implode(',', array_map('intval', $groups)) . ')');
            $gids = $db->loadColumn();
        } catch (Exception $e) {
            return null;
        } catch (Throwable $e) {
            return null;
        }

        $gids = is_array($gids) ? array_values(array_filter(array_map('intval', $gids), function ($g) { return $g > 0; })) : array();

        if (empty($gids)) {
            return null; // kein Super-User
        }

        try {
            $db->setQuery('SELECT ' . $db->quoteName(array('id', 'username', 'email', 'block', 'registerDate')) . ' FROM ' . $db->quoteName('#__users') . ' WHERE ' . $db->quoteName('id') . ' = ' . $id);
            $r = $db->loadAssoc();
        } catch (Exception $e) {
            return null;
        } catch (Throwable $e) {
            return null;
        }

        if (!is_array($r) || empty($r)) {
            return null;
        }

        return array(
            'id'         => $id,
            'username'   => (string) (isset($r['username']) ? $r['username'] : ''),
            'email'      => (string) (isset($r['email']) ? $r['email'] : ''),
            'block'      => (int) (isset($r['block']) ? $r['block'] : 0),
            'registered' => (string) (isset($r['registerDate']) ? $r['registerDate'] : ''),
            'groups'     => $gids,
        );
    }

    /** Login-Zeitpunkt-Schutz: legt ein sich gerade einloggendes, eindeutig eingeschleustes
     *  Super-User-Konto SOFORT still (block + Gruppen entfernen + Journal) und macht dessen Sessions
     *  ungueltig - so entfaellt das 15-Minuten-Fenster bis zum naechsten Wachlauf. Fail-open (nie ein
     *  legitimes Login stoeren). Gibt bei erfolgter Sperre {id,username} zurueck, sonst false.
     *
     *  @param  array    $cfg  Config (Referenz; Journal via saveKeys)
     *  @param  integer  $uid  Der gerade eingeloggte Nutzer
     *
     *  @return array|boolean
     */
    public static function guardLogin(&$cfg, $uid)
    {
        if (!is_array($cfg) || empty($cfg['admin_guard']) || !class_exists('HtpConfig')) {
            return false;
        }

        $mode = isset($cfg['admin_guard_mode']) ? (string) $cfg['admin_guard_mode'] : 'contain';

        if ($mode !== 'contain') {
            return false;
        }

        $uid = (int) $uid;

        if ($uid <= 1) {
            return false;
        }

        $acct = self::loadSuperUser($uid);

        if ($acct === null) {
            return false; // kein Super-User (oder DB unsicher) -> nichts tun
        }

        // Frisch aus dem Store: Baseline-Uebernahme + Sanktionen aus parallelen Requests sehen.
        $src = HtpConfig::load();

        if (!is_array($src)) {
            $src = $cfg;
        }

        $baseline   = (isset($src['account_baseline']) && is_array($src['account_baseline'])) ? $src['account_baseline'] : array();
        $whitelist  = (isset($src['admin_whitelist']) && is_array($src['admin_whitelist'])) ? $src['admin_whitelist'] : array();
        $sanctioned = (isset($src['account_sanctioned']) && is_array($src['account_sanctioned'])) ? $src['account_sanctioned'] : array();
        $journal    = (isset($src['account_contained']) && is_array($src['account_contained'])) ? $src['account_contained'] : array();

        if (empty($baseline)) {
            return false; // Erstlauf/keine Baseline -> beim Login nie sperren
        }

        $now = time();

        if (!self::loginBlockDecision($acct, $baseline, $whitelist, $sanctioned, self::countActiveSuperUsers(), $now, self::SANCTION_GRACE)) {
            return false;
        }

        // Journal-Deckel (wie guardTick).
        if (count($journal) >= self::JOURNAL_MAX && !isset($journal[$uid])) {
            $oldestId = null;
            $oldestTs = PHP_INT_MAX;

            foreach ($journal as $jid => $j) {
                $jts = (int) (isset($j['ts']) ? $j['ts'] : 0);

                if ($jts < $oldestTs) {
                    $oldestTs = $jts;
                    $oldestId = $jid;
                }
            }

            if ($oldestId !== null) {
                unset($journal[$oldestId]);
            }
        }

        $grps    = (isset($acct['groups']) && is_array($acct['groups'])) ? array_values(array_map('intval', $acct['groups'])) : array();
        $prevBlk = (int) (isset($acct['block']) ? $acct['block'] : 0);

        // Journal VOR jeder Mutation; laesst es sich nicht persistieren -> NICHT sperren. Einen schon
        // bestehenden Journal-Eintrag NICHT ueberschreiben (echter Vorher-Zustand fuers Freigeben).
        $journal[$uid] = array(
            'block'      => (isset($journal[$uid]['block']) ? (int) $journal[$uid]['block'] : $prevBlk),
            'groups'     => (isset($journal[$uid]['groups']) && is_array($journal[$uid]['groups']) && !empty($journal[$uid]['groups'])) ? array_values(array_map('intval', $journal[$uid]['groups'])) : $grps,
            'registered' => (string) (isset($acct['registered']) ? $acct['registered'] : ''),
            'username'   => (string) (isset($acct['username']) ? $acct['username'] : ''),
            'ts'         => (isset($journal[$uid]['ts']) ? (int) $journal[$uid]['ts'] : $now),
        );

        if (HtpConfig::saveKeys(array('account_contained' => $journal)) === false) {
            return false;
        }

        if (!self::blockUser($uid)) {
            unset($journal[$uid]);
            HtpConfig::saveKeys(array('account_contained' => $journal));

            return false;
        }

        self::stripPrivilegedGroups($uid, $grps);

        // Race-Sicherung (zwei gleichzeitige eingeschleuste Logins koennten die serielle „>=2"-Pruefung
        // umlaufen): bleibt nach dem Sperren KEIN aktiver Super-User uebrig, die Sperre SOFORT zuruecknehmen
        // (nie den letzten aussperren) - der naechste Wachlauf faengt das Konto ohnehin wieder.
        if (self::countActiveSuperUsers() < 1) {
            self::restoreUser($uid, $prevBlk, $grps);
            unset($journal[$uid]);
            HtpConfig::saveKeys(array('account_contained' => $journal));
            $cfg['account_contained'] = $journal;

            return false;
        }

        // Sessions dieses Kontos ungueltig machen, damit der Angreifer ab dem Folge-Request draussen
        // ist. Die eigentliche Eindaemmung traegt bereits block=1 + entzogene Gruppen; dies ist
        // best-effort (version-agnostisch per DB, kein JUserHelper/UserHelper-Split).
        $db = htp_db();

        if ($db) {
            try {
                $db->setQuery('DELETE FROM ' . $db->quoteName('#__session') . ' WHERE ' . $db->quoteName('userid') . ' = ' . $uid);
                $db->execute();
            } catch (Exception $e) {
            } catch (Throwable $e) {
            }
        }

        // KEINE separate Melde-Warteschlange: der naechste Wachlauf meldet dieses Konto ohnehin als
        // acct_stopped direkt aus dem dauerhaften Journal (guardTick) - robust + ohne Verlust/Race.
        $cfg['account_contained'] = $journal;

        return array('id' => $uid, 'username' => (string) (isset($acct['username']) ? $acct['username'] : ''));
    }

    /**
     * Einen Vorfall bauen. Schluessel path|kind ist mit der Anti-Spam-Pipeline
     * von HtpWatch kompatibel (eine Mail je Konto+Aenderungsart).
     *
     * @param   integer  $id    Benutzer-ID
     * @param   string   $kind  acct_new|acct_pw|acct_email
     * @param   string   $user  Benutzername
     *
     * @return array
     */
    private static function event($id, $kind, $user, $old = '', $new = '', $vraw = '')
    {
        static $keys = array(
            'acct_new'      => 'COM_HTPROTECT_ACCOUNTS_M_NEW',
            'acct_pw'       => 'COM_HTPROTECT_ACCOUNTS_M_PW',
            'acct_email'    => 'COM_HTPROTECT_ACCOUNTS_M_EMAIL',
            // Admin-Schutz: eingeschleustes Konto stillgelegt bzw. Eigentuemer wiederhergestellt.
            'acct_stopped'  => 'COM_HTPROTECT_ACCOUNTS_M_STOPPED',
            'acct_restored' => 'COM_HTPROTECT_ACCOUNTS_M_RESTORED',
        );

        $old = (string) $old;
        $new = (string) $new;

        // Wert-Diskriminator fuer die Anti-Spam-Pipeline (HtpWatch::incidentKey):
        // nur ein kurzer Hash des tatsaechlich geaenderten Werts, NIE Klartext im
        // gespeicherten Melde-Status. Dadurch loest jede INHALTLICH neue Aenderung
        // genau eine weitere Mail aus, waehrend eine UNveraenderte Lage weiterhin
        // gedrosselt bleibt. Leer (z. B. neuer Account) -> Verhalten exakt wie bisher.
        $vkey = ($vraw !== '') ? substr(sha1((string) $vraw), 0, 16) : '';

        // Label inkl. alt -> neu, wo darstellbar. Beim Passwort gibt es keinen
        // anzeigbaren Wert (nur Hashes); neuer Account zeigt Benutzer (+ E-Mail).
        if ($kind === 'acct_email') {
            $label = htp_t('COM_HTPROTECT_ACCOUNTS_M_EMAIL', (string) $user,
                ($old !== '' ? $old : '-'), ($new !== '' ? $new : '-'));
        } elseif ($kind === 'acct_new') {
            $who   = ($new !== '') ? ((string) $user . ' <' . $new . '>') : (string) $user;
            $label = htp_t('COM_HTPROTECT_ACCOUNTS_M_NEW', $who);
        } else {
            $label = htp_t(isset($keys[$kind]) ? $keys[$kind] : $keys['acct_new'], (string) $user);
        }

        return array(
            'path'  => 'account:' . (int) $id,
            'kind'  => $kind,
            'id'    => (int) $id,
            'user'  => (string) $user,
            'old'   => $old,
            'new'   => $new,
            'vkey'  => $vkey,
            'label' => $label,
        );
    }

    /**
     * Feld aus einem Eintrag (string, leer falls fehlend).
     */
    private static function f($a, $k)
    {
        return is_array($a) && isset($a[$k]) ? (string) $a[$k] : '';
    }

    /**
     * Ist die Hash-Aenderung eines Super-Users nur Joomlas transparenter
     * Login-Rehash (Passwort UNveraendert)? Streng, damit eine echte Aenderung
     * niemals durchrutscht. Alle Bedingungen muessen zutreffen:
     *   - der Passwort-Hash hat sich geaendert,
     *   - der NEUE Hash ist aktuell (nicht erneut rehash-faellig),
     *   - der ALTE Hash war veraltet (NUR dann erneuert Joomla ihn beim Login;
     *     war er bereits aktuell, kann ein Login ihn nicht aendern -> echte
     *     Aenderung). Bei alter Baseline ohne dieses Feld unbekannt -> der
     *     Login-Nachweis unten traegt die Sicherheit,
     *   - der Nutzer hat sich SEIT der Baseline eingeloggt (der Rehash passiert
     *     genau beim Login). Ohne gespeicherten Baseline-Login: kuerzlich aktiv.
     *
     * @param   array  $b    Baseline-Eintrag
     * @param   array  $cur  Aktueller Eintrag
     *
     * @return boolean
     */
    public static function isBenignRehash($b, $cur)
    {
        if (!is_array($b) || !is_array($cur)) {
            return false;
        }

        $oldHash = self::f($b, 'phash');
        $newHash = self::f($cur, 'phash');

        if ($newHash === '' || $newHash === $oldHash) {
            return false;
        }

        // Neuer Hash muss aktuell sein.
        if (!empty(self::f($cur, 'pneeds'))) {
            return false;
        }

        // Alter Hash war bereits aktuell -> kein Login-Rehash moeglich -> echt.
        if (array_key_exists('pneeds', $b) && empty(self::f($b, 'pneeds'))) {
            return false;
        }

        $cVisit = self::f($cur, 'lastvisit');
        $bVisit = self::f($b, 'lastvisit');

        if ($cVisit === '' || strpos($cVisit, '0000-00-00') === 0) {
            return false;
        }

        // Mit gespeichertem Baseline-Login: exakter Nachweis (Login danach).
        if ($bVisit !== '') {
            return $cVisit !== $bVisit;
        }

        // Alte Baseline ohne Login-Stand: kuerzlicher Login (absolutes Fenster).
        return self::visitedWithinDays($cVisit, 45);
    }

    /**
     * Wuerde Joomla diesen Passwort-Hash beim naechsten Login erneuern (anderes
     * Verfahren / andere Kosten als der aktuelle Standard)? Spiegelt exakt
     * password_needs_rehash(..., PASSWORD_DEFAULT) wie Joomlas UserHelper.
     *
     * @param   string  $hash  Roher Passwort-Hash aus #__users.password
     *
     * @return boolean
     */
    private static function passwordOutdated($hash)
    {
        $hash = (string) $hash;

        if ($hash === '' || !function_exists('password_needs_rehash')) {
            return false;
        }

        // Den Rehash-Check gegen DEN PASSENDEN Algorithmus laufen lassen - genau wie
        // Joomlas UserHelper::verifyPassword den Handler am Hash-Praefix waehlt. Sonst
        // gilt ein voellig aktueller Argon2-Hash gegen PASSWORD_DEFAULT (= bcrypt)
        // faelschlich als "veraltet" (pneeds=1), isBenignRehash kann den legitimen
        // Login-Rehash nicht als gutartig erkennen -> Fehlalarm "Passwort geaendert"
        // (genau der Forenfall auf einer Argon2-Site; reines bcrypt ist nicht betroffen).
        if (strncmp($hash, '$argon2id', 9) === 0 && defined('PASSWORD_ARGON2ID')) {
            $algo = PASSWORD_ARGON2ID;
        } elseif (strncmp($hash, '$argon2i', 8) === 0 && defined('PASSWORD_ARGON2I')) {
            $algo = PASSWORD_ARGON2I;
        } elseif (strncmp($hash, '$2', 2) === 0 && defined('PASSWORD_BCRYPT')) {
            $algo = PASSWORD_BCRYPT;
        } else {
            // Legacy/unbekannt ($P$ PHPass, md5, ...): aus unserer Sicht veraltet -
            // Joomla rehasht es beim Login auf bcrypt (neuer Hash gilt dann als aktuell,
            // -> alt pneeds=1 / neu pneeds=0 -> isBenignRehash erkennt es korrekt).
            return true;
        }

        try {
            return (bool) password_needs_rehash($hash, $algo);
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }
    }

    /**
     * Liegt der (MySQL-)Zeitstempel innerhalb der letzten N Tage?
     *
     * @param   string   $dateStr  Datum (z. B. lastvisitDate)
     * @param   integer  $days     Fenster in Tagen
     *
     * @return boolean
     */
    private static function visitedWithinDays($dateStr, $days)
    {
        $ts = @strtotime((string) $dateStr);

        if ($ts === false || $ts <= 0) {
            return false;
        }

        return $ts >= (time() - (int) $days * 86400);
    }
}
