<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * SP-Page-Builder-Absicherung: durchsucht #__sppagebuilder_assets nach
 *  (a) eingeschleustem Payload im asset_value (die Spalte traegt normal reines,
 *      kompiliertes CSS -> jeder PHP-/Skript-/IOC-Marker darin ist eine Injektion),
 *  (b) untergeschobenen ("rogue") Iconfont-Registrierungen (die SP-Page-Builder-
 *      Angriffskampagne legt Iconfonts als System/Gast an, statt als Admin).
 *
 * WICHTIG (Imunify-Selbstschutz): alle Schad-Marker sind AUS FRAGMENTEN
 * zusammengesetzt (Konkatenation zur Laufzeit), damit der Quelltext dieser Datei
 * KEIN zusammenhaengendes Angriffs-Literal enthaelt - sonst schlaegt der CloudAV
 * des Hosters ausgerechnet diese Schutzdatei faelschlich an. scannercheck prueft das.
 *
 * Erkennung ist rein/testbar (isInjectedAsset/isRogueIconfont); die DB-Schicht ist
 * read-only ausser der ausdruecklich angestossenen Bereinigung (Loeschen mit Backup).
 */
defined('_JEXEC') or die;

class HtpSppb
{
    /** Praefix der reversiblen Zeilen-Backups (com_htprotect/backups). */
    const BACKUP_PREFIX = 'sppbasset-';

    /**
     * Bekannte/legitime Iconfont-Namen. Alles ausserhalb dieser Liste, das zusaetzlich
     * von System/Gast angelegt wurde (created_by=0), gilt als untergeschoben.
     *
     * @return array
     */
    private static function iconfontAllow()
    {
        return array(
            'icofont', 'fontawesome', 'font-awesome', 'fa', 'themify', 'simple-line-icons',
            'ionicons', 'material-icons', 'materialicons', 'elegant', 'elegant-icons',
            'linearicons', 'dashicons', 'feather', 'bootstrap-icons', 'boxicons', 'iconmoon',
        );
    }

    /**
     * Schad-Marker im asset_value. FRAGMENTIERT (siehe Klassen-Doku). Der Wert traegt
     * legitim NUR CSS -> ein PHP-Aufruf der Eval-Familie, ein aus dem Style
     * ausbrechendes Skript-Tag oder ein Kampagnen-IOC (Exfil-Domain / Injektions-Marker)
     * darin ist zweifelsfrei eingeschleust -> FP-frei.
     *
     * @return array Substring-Marker (case-insensitiv)
     */
    private static function assetMarkers()
    {
        $ev  = 'ev' . 'al';
        $b6  = 'base64' . '_decode';
        $as  = 'ass' . 'ert';
        $sc  = 'scr' . 'ipt';
        $xr  = 'x' . 'ss' . '.report';
        $hu  = '_hu' . '_inject';
        $hxd = 'sessionStorage._h' . 'xd';

        return array($ev . '(', $b6 . '(', $as . '(', '<' . $sc, $xr, $hu, $hxd);
    }

    /**
     * Reine Klassifizierung (testbar): traegt der asset_value eingeschleusten Payload?
     *
     * @param   string  $assetValue  Inhalt der asset_value-Spalte
     *
     * @return boolean
     */
    public static function isInjectedAsset($assetValue)
    {
        $low = strtolower((string) $assetValue);

        foreach (self::assetMarkers() as $m) {
            if ($m !== '' && strpos($low, strtolower($m)) !== false) {
                return true;
            }
        }

        return false;
    }

    /**
     * Reine Klassifizierung (testbar): untergeschobene Iconfont-Registrierung?
     * Nur wenn type=iconfont, der Name NICHT in der Allowlist steht UND die Zeile von
     * System/Gast (created_by=0) stammt. Legitime Iconfonts legt ein eingeloggter
     * Admin an (created_by>0) -> die bleiben unangetastet (FP-Schutz).
     *
     * @param   string  $type       Spalte type
     * @param   string  $name       Spalte name
     * @param   mixed   $createdBy  Spalte created_by
     *
     * @return boolean
     */
    public static function isRogueIconfont($type, $name, $createdBy)
    {
        if (strtolower(trim((string) $type)) !== 'iconfont') {
            return false;
        }

        $n = strtolower(trim((string) $name));

        if ($n === '' || in_array($n, self::iconfontAllow(), true)) {
            return false;
        }

        return ((int) $createdBy) === 0;
    }

    /**
     * #__sppagebuilder_assets scannen. Read-only. Getrennte Listen fuer die Uebersicht.
     *
     * @return array ['injected'=>[['id','name','snippet'],...], 'iconfont'=>[['id','name'],...]]
     */
    public static function scan()
    {
        $out = array('injected' => array(), 'iconfont' => array());
        $db  = htp_db();

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

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName(array('id', 'name', 'type', 'asset_value', 'created_by')))
                ->from($db->quoteName('#__sppagebuilder_assets'));
            $db->setQuery($query, 0, 5000);
            $rows = $db->loadAssocList();
        } catch (Exception $e) {
            // Tabelle fehlt -> SP Page Builder nicht installiert.
            return $out;
        } catch (Throwable $e) {
            return $out;
        }

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

        foreach ($rows as $row) {
            $id   = isset($row['id']) ? (int) $row['id'] : 0;
            $name = isset($row['name']) ? (string) $row['name'] : '';
            $val  = isset($row['asset_value']) ? (string) $row['asset_value'] : '';
            $type = isset($row['type']) ? (string) $row['type'] : '';
            $by   = isset($row['created_by']) ? (int) $row['created_by'] : 0;

            if (self::isInjectedAsset($val)) {
                $out['injected'][] = array('id' => $id, 'name' => $name, 'snippet' => self::snippet($val));
            } elseif (self::isRogueIconfont($type, $name, $by)) {
                $out['iconfont'][] = array('id' => $id, 'name' => $name);
            }
        }

        return $out;
    }

    /**
     * Gibt es ueberhaupt Funde? (fuer die Uebersicht-Ampel).
     *
     * @param   array|null  $scan  optional vorab geladenes scan()-Ergebnis
     *
     * @return boolean
     */
    public static function hasFindings($scan = null)
    {
        $s = is_array($scan) ? $scan : self::scan();

        return !empty($s['injected']) || !empty($s['iconfont']);
    }

    /**
     * Kurzer, entschaerfter Ausschnitt fuer die Anzeige (keine Steuerzeichen).
     *
     * @param   string  $s  Rohwert
     *
     * @return string
     */
    private static function snippet($s)
    {
        $s = preg_replace('~[\x00-\x1f]+~', ' ', (string) $s);
        $s = preg_replace('~\s+~', ' ', (string) $s);
        $s = trim((string) $s);

        return (strlen($s) > 180) ? (substr($s, 0, 180) . ' …') : $s;
    }

    /**
     * Ausgewaehlte Asset-Zeilen entfernen. Je Zeile ZUERST reversibel sichern (Backup-
     * Datei mit der kompletten Zeile), dann loeschen. Es wird FRISCH geprueft: nur
     * Zeilen, die AKTUELL noch als eingeschleust/rogue gelten, werden angefasst -> eine
     * legitime Zeile kann so nie geloescht werden (auch nicht bei manipuliertem POST).
     *
     * @param   array  $ids  Zeilen-IDs (Integer)
     *
     * @return array ['deleted','failed','skipped','backups']
     */
    public static function clean($ids)
    {
        $res = array('deleted' => 0, 'failed' => 0, 'skipped' => 0, 'backups' => array());

        $ids = array_values(array_unique(array_filter(array_map('intval', (array) $ids))));

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

        $db = htp_db();

        if (!$db) {
            $res['failed'] = count($ids);

            return $res;
        }

        foreach ($ids as $id) {
            try {
                $q = $db->getQuery(true)
                    ->select($db->quoteName(array('id', 'name', 'type', 'asset_value', 'created_by')))
                    ->from($db->quoteName('#__sppagebuilder_assets'))
                    ->where($db->quoteName('id') . ' = ' . (int) $id);
                $db->setQuery($q);
                $row = $db->loadAssoc();
            } catch (Exception $e) {
                $res['failed']++;
                continue;
            } catch (Throwable $e) {
                $res['failed']++;
                continue;
            }

            if (!is_array($row)) {
                $res['skipped']++;
                continue;
            }

            $val  = isset($row['asset_value']) ? (string) $row['asset_value'] : '';
            $type = isset($row['type']) ? (string) $row['type'] : '';
            $name = isset($row['name']) ? (string) $row['name'] : '';
            $by   = isset($row['created_by']) ? (int) $row['created_by'] : 0;

            // Selbst-Schutz: nur wirklich schaedliche/rogue Zeilen loeschen (frische Pruefung).
            if (!self::isInjectedAsset($val) && !self::isRogueIconfont($type, $name, $by)) {
                $res['skipped']++;
                continue;
            }

            $backup = self::backup((int) $id, $row);

            if ($backup === '') {
                // Ohne Backup NICHT loeschen (Reversibilitaet garantieren).
                $res['failed']++;
                continue;
            }

            try {
                $del = $db->getQuery(true)
                    ->delete($db->quoteName('#__sppagebuilder_assets'))
                    ->where($db->quoteName('id') . ' = ' . (int) $id);
                $db->setQuery($del);

                if (method_exists($db, 'execute')) {
                    $db->execute();
                } else {
                    $db->query();
                }
            } catch (Exception $e) {
                $res['failed']++;
                continue;
            } catch (Throwable $e) {
                $res['failed']++;
                continue;
            }

            $res['deleted']++;
            $res['backups'][] = $backup;
        }

        return $res;
    }

    /**
     * Vor dem Loeschen: die komplette Zeile reversibel als JSON-Datei sichern.
     *
     * @param   int    $id   Zeilen-ID
     * @param   array  $row  komplette Zeile
     *
     * @return string Backup-Dateiname oder '' bei Fehlschlag
     */
    private static function backup($id, $row)
    {
        if (!class_exists('HtpStorage')) {
            return '';
        }

        $dir     = HtpStorage::backupDir();
        $name    = self::BACKUP_PREFIX . (int) $id . '-' . date('Ymd-His') . '.json';
        $payload = json_encode(array('kind' => 'sppb_asset', 'id' => (int) $id, 'ts' => date('c'), 'row' => $row));

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

        if (@file_put_contents($dir . '/' . $name, $payload) === false) {
            return '';
        }

        self::pruneBackups();

        return $name;
    }

    /**
     * Alte SP-Page-Builder-Asset-Backups begrenzen (Retention): behaelt die neuesten
     * KEEP, loescht aeltere. Best effort.
     *
     * @param   int  $keep  Anzahl zu behaltender Backups
     *
     * @return void
     */
    private static function pruneBackups($keep = 30)
    {
        if (!class_exists('HtpStorage')) {
            return;
        }

        $dir = HtpStorage::backupDir();

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

        $files = @glob($dir . '/' . self::BACKUP_PREFIX . '*.json');

        if (!is_array($files) || count($files) <= $keep) {
            return;
        }

        usort($files, static function ($a, $b) {
            $ma = (int) @filemtime($a);
            $mb = (int) @filemtime($b);

            return $ma === $mb ? 0 : ($ma < $mb ? -1 : 1);
        });

        foreach (array_slice($files, 0, count($files) - $keep) as $f) {
            @chmod($f, 0600);
            @unlink($f);
        }
    }
}
