<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * Schwarm-Schutz: die BEITRAGS-Seite des Threat-Netzwerks. Ein Admin kann
 * bewusst (Super-User + CSRF + Einwilligung) Fehlalarme (False Positives aus der
 * Nutzer-Whitelist) und uebersehene Schaddateien (False Negatives, per Pfad)
 * an das Melde-Portal schicken; Pascal kuratiert daraus Signaturen, die ueber
 * die signierte malware.json an ALLE Installationen ausgespielt werden.
 *
 * Leitplanken: maximale Einfachheit (FP = ein Klick, FN = Pfade einfuegen);
 * KEINE Telemetrie (jede Uebertragung ist eine bewusste, eingewilligte Aktion);
 * anonym (nur eine zufaellige contrib_id, kein Host/keine IP); docroot-begrenzt;
 * ausgehend nur an die feste, HMAC-signierte Portal-URL; faellt nie hart aus.
 */

defined('_JEXEC') or die;

class HtpSwarm
{
    /** Versionsnummer des Report-Vertrags. */
    const CONTRACT_V = 1;

    /** Obergrenzen (Missbrauch/Speicher). */
    const MAX_FILES = 25;
    const MAX_FILE  = 8388608;   // 8 MB je Datei
    const MAX_TOTAL = 16777216;  // 16 MB gesamt
    const TIMEOUT   = 8;         // Sekunden fuer den Upload

    /**
     * Anonyme, pro Installation persistente Beitrags-ID (32 hex). Dient NUR der
     * geschlossenen Rueckkopplung (Feed nennt sie in 'ack' zurueck -> "deine
     * Meldung ist live"). Kein PII, kein Host. Lazy erzeugt.
     *
     * @param   array  $cfg  Konfiguration (Referenz; ID wird ggf. gesetzt+gespeichert)
     *
     * @return string
     */
    public static function contribId(&$cfg)
    {
        $id = isset($cfg['swarm_contrib_id']) ? (string) $cfg['swarm_contrib_id'] : '';

        if (preg_match('~^[a-f0-9]{32}$~', $id)) {
            return $id;
        }

        $bytes = function_exists('htp_random_bytes') ? htp_random_bytes(16) : '';

        if (!is_string($bytes) || strlen($bytes) < 16) {
            $bytes = substr(sha1(uniqid('', true) . mt_rand(), true) . sha1(microtime(), true), 0, 16);
        }

        $id = bin2hex(substr($bytes, 0, 16));
        $cfg['swarm_contrib_id'] = $id;

        if (class_exists('HtpConfig')) {
            HtpConfig::saveKeys(array('swarm_contrib_id' => $id));
        }

        return $id;
    }

    /**
     * EINEN relativen Pfad sicher in einen absoluten aufloesen - NUR wenn er
     * innerhalb des Docroots liegt, eine regulaere (Nicht-Symlink-)Datei ist.
     * Rein/testbar mit echten Dateien. '' = abgelehnt (Traversal/ausserhalb/fehlt).
     *
     * @param   string  $root  Docroot (absolut)
     * @param   string  $rel   Relativer Pfad
     *
     * @return string  Absoluter Pfad oder ''
     */
    public static function resolveOne($root, $rel)
    {
        $root = rtrim(str_replace('\\', '/', (string) $root), '/');
        $rel  = ltrim(trim(str_replace('\\', '/', (string) $rel)), '/');

        if ($root === '' || $rel === '') {
            return '';
        }

        // Offensichtliche Traversal/Null-Bytes sofort raus.
        if (strpos($rel, '..') !== false || strpos($rel, "\0") !== false) {
            return '';
        }

        $abs = $root . '/' . $rel;

        if (!is_file($abs) || is_link($abs)) {
            return '';
        }

        // Harte Grenze: realpath MUSS im Docroot liegen (kein Symlink-Ausbruch).
        $real     = realpath($abs);
        $realRoot = realpath($root);

        if ($real === false || $realRoot === false) {
            return '';
        }

        $real     = str_replace('\\', '/', $real);
        $realRoot = rtrim(str_replace('\\', '/', $realRoot), '/');

        if ($real !== $realRoot && strpos($real, $realRoot . '/') !== 0) {
            return '';
        }

        return $abs;
    }

    /**
     * Mehrere Pfade aufloesen + Grenzen anwenden (Anzahl/Groesse/Gesamt).
     *
     * @param   array  $paths  Liste relativer Pfade
     *
     * @return array  [relPfad => absPfad]
     */
    public static function resolvePaths($paths)
    {
        $root = rtrim(str_replace('\\', '/', (string) (function_exists('htp_root') ? htp_root() : '')), '/');
        $out  = array();
        $total = 0;

        if ($root === '') {
            return $out;
        }

        foreach ((array) $paths as $rel) {
            if (count($out) >= self::MAX_FILES) {
                break;
            }

            $abs = self::resolveOne($root, (string) $rel);

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

            $size = (int) @filesize($abs);

            if ($size <= 0 || $size > self::MAX_FILE || $total + $size > self::MAX_TOTAL) {
                continue;
            }

            $relN = ltrim((string) substr(str_replace('\\', '/', $abs), strlen($root)), '/');

            if (isset($out[$relN])) {
                continue;
            }

            $out[$relN] = $abs;
            $total += $size;
        }

        return $out;
    }

    /**
     * Reale Dateien aus der NUTZER-Whitelist (mal_whitelist) - Globs/Pfade.
     * Die eingebaute Whitelist (bfnetwork/phpshield/admintools) ist NICHT Teil
     * von mal_whitelist und wird daher nie mitgeschickt.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array  [relPfad => absPfad]
     */
    public static function whitelistFiles($cfg)
    {
        $wl   = (isset($cfg['mal_whitelist']) && is_array($cfg['mal_whitelist'])) ? $cfg['mal_whitelist'] : array();
        $root = rtrim(str_replace('\\', '/', (string) (function_exists('htp_root') ? htp_root() : '')), '/');
        $rels = array();

        if ($root === '') {
            return array();
        }

        foreach ($wl as $pat) {
            $pat = ltrim(trim(str_replace('\\', '/', (string) $pat)), '/');

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

            $matches = @glob($root . '/' . $pat);

            if (is_array($matches) && !empty($matches)) {
                foreach ($matches as $m) {
                    if (is_file($m) && !is_link($m)) {
                        $rels[] = ltrim((string) substr(str_replace('\\', '/', $m), strlen($root)), '/');
                    }
                }
            } elseif (is_file($root . '/' . $pat)) {
                $rels[] = $pat;
            }
        }

        return self::resolvePaths($rels);
    }

    /**
     * Report-Manifest bauen (rein/testbar). Liest je Datei sha256 + aktuelles
     * Scanner-Urteil (FP: die faelschlich greifende Signatur; FN: '' bestaetigt
     * den Miss). Kein Host, keine IP -> Anonymitaet.
     *
     * @param   string  $type       'fp' | 'fn'
     * @param   string  $contribId  Anonyme Beitrags-ID
     * @param   array   $files      [relPfad => absPfad]
     * @param   string  $note       Optionale Notiz des Melders
     *
     * @return array
     */
    public static function buildManifest($type, $contribId, $files, $note = '')
    {
        $type = in_array($type, array('fn', 'fp', 'update', 'fb'), true) ? $type : 'fp';
        $list = array();

        foreach ($files as $rel => $abs) {
            $content = (string) @file_get_contents($abs, false, null, 0, self::MAX_FILE);
            $verdict = (class_exists('HtpStorage') && method_exists('HtpStorage', 'malwareSignature'))
                ? (string) HtpStorage::malwareSignature($content)
                : '';

            $list[] = array(
                'path'    => (string) $rel,
                'sha256'  => hash('sha256', $content),
                'size'    => (int) @filesize($abs),
                'mtime'   => (int) @filemtime($abs),
                'verdict' => $verdict,
            );
        }

        return array(
            'v'          => self::CONTRACT_V,
            'type'       => $type,
            'contrib_id' => (string) $contribId,
            'submitted'  => gmdate('Y-m-d\TH:i:s\Z'),
            'env'        => array(
                'jversion'   => self::jVersion(),
                'phpversion' => PHP_VERSION,
                'htpversion' => defined('HTP_VERSION') ? HTP_VERSION : '',
            ),
            'contact'    => '',
            'note'       => (string) substr((string) $note, 0, 500),
            'files'      => $list,
        );
    }

    /**
     * Die Dateien als (Plain-)ZIP packen - bewusst OHNE Passwort, weil ZIP-
     * Verschluesselung nicht ueberall verfuegbar ist (Kompatibilitaet); der
     * Schutz liegt in HTTPS + isolierter Portal-Ablage + Sandbox-Analyse.
     *
     * @param   array  $files  [relPfad => absPfad]
     *
     * @return array  ['ok'=>bool, 'data'=>string] | ['ok'=>false, 'reason'=>string]
     */
    public static function buildZip($files)
    {
        if (empty($files)) {
            return array('ok' => false, 'reason' => 'empty');
        }

        if (!class_exists('ZipArchive')) {
            return array('ok' => false, 'reason' => 'nozip');
        }

        $tmp = self::tmpFile();

        if ($tmp === '') {
            return array('ok' => false, 'reason' => 'notmp');
        }

        $zip = new ZipArchive();

        if ($zip->open($tmp, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
            @unlink($tmp);

            return array('ok' => false, 'reason' => 'open');
        }

        foreach ($files as $rel => $abs) {
            $data = @file_get_contents($abs, false, null, 0, self::MAX_FILE);

            if ($data !== false) {
                $zip->addFromString('files/' . $rel, $data);
            }
        }

        $zip->close();

        $bytes = (string) @file_get_contents($tmp);
        @unlink($tmp);

        if ($bytes === '') {
            return array('ok' => false, 'reason' => 'read');
        }

        return array('ok' => true, 'data' => $bytes);
    }

    /**
     * Kompletter, fail-soft Melde-Vorgang: Dateien aufloesen -> Manifest -> ZIP
     * -> signierter Upload. Liefert ein Ergebnis fuer die UI.
     *
     * @param   array   $cfg    Konfiguration (Referenz; contrib_id ggf. gesetzt)
     * @param   string  $type   'fp' | 'fn'
     * @param   array   $paths  Bei 'fn': gemeldete Pfade. Bei 'fp': ignoriert (Whitelist).
     * @param   string  $note   Optionale Notiz
     *
     * @return array  ['ok'=>bool, 'count'=>int, 'error'=>string]
     */
    public static function report(&$cfg, $type, $paths = array(), $note = '', $meta = array())
    {
        // 'update' = automatische Meldung eines vom Update-Inhalts-Scan geflaggten
        // Update-Funds (FP-Kandidat/echter Schadcode) - eigener Typ, damit radar es
        // getrennt vom Nutzer-FP/FN einordnet. Nutzt wie 'fn' die uebergebenen Pfade.
        // $meta (optional, z. B. bei 'update': element/version/secupd) erlaubt radar,
        // praezise Holds abzuleiten, ohne die Notiz parsen zu muessen.
        $type  = in_array($type, array('fn', 'fp', 'update'), true) ? $type : 'fp';
        $files = ($type === 'fp') ? self::whitelistFiles($cfg) : self::resolvePaths($paths);
        // Hinweis: 'fb' (Update-Feedback) hat KEINE Dateien -> eigener, datei-loser
        // Pfad ueber reportSignal()/submitSignal(); report() bleibt fuer Datei-Meldungen.

        if (empty($files)) {
            return array('ok' => false, 'count' => 0, 'status' => 0, 'error' => 'nofiles', 'reason' => 'nofiles');
        }

        $id  = self::contribId($cfg);
        $man = self::buildManifest($type, $id, $files, $note);

        if (is_array($meta) && !empty($meta)) {
            $man['meta'] = $meta;
        }

        $zip = self::buildZip($files);

        if (empty($zip['ok'])) {
            return array(
                'ok'     => false,
                'count'  => count($files),
                'status' => 0,
                'error'  => 'zip:' . (isset($zip['reason']) ? $zip['reason'] : '?'),
                'reason' => 'zip',
            );
        }

        $sent   = self::submit($man, $zip['data']);
        $ok     = !empty($sent['ok']);
        $status = (int) (isset($sent['status']) ? $sent['status'] : 0);

        return array(
            'ok'     => $ok,
            'count'  => count($files),
            'status' => $status,
            'error'  => $ok ? '' : ('send:' . $status),
            'reason' => $ok ? '' : (isset($sent['reason']) && $sent['reason'] !== '' ? (string) $sent['reason'] : htp_http_reason(0, $status)),
        );
    }

    /**
     * Signierter multipart-Upload an das Melde-Portal. HMAC ueber das Manifest-
     * JSON (Abuse-Schutz des Endpunkts, kein Security-Boundary). Fail-soft.
     *
     * @param   array   $manifest  Manifest-Array
     * @param   string  $zip       ZIP-Bytes
     *
     * @return array  ['ok'=>bool, 'status'=>int]
     */
    public static function submit($manifest, $zip)
    {
        $json = json_encode($manifest);

        if (!is_string($json) || $json === '') {
            return array('ok' => false, 'status' => 0);
        }

        $key = (class_exists('HtpDefs') && defined('HtpDefs::SWARM_KEY')) ? HtpDefs::SWARM_KEY : '';
        $url = (class_exists('HtpDefs') && defined('HtpDefs::SWARM_URL')) ? HtpDefs::SWARM_URL : '';

        if ($key === '' || $url === '' || !function_exists('htp_http_post_multipart')) {
            return array('ok' => false, 'status' => 0);
        }

        $fields = array(
            'htp_report' => $json,
            'htp_sig'    => hash_hmac('sha256', $json, $key),
        );
        $file = array('name' => 'htp_sample', 'filename' => 'sample.zip', 'type' => 'application/zip', 'data' => (string) $zip);

        $res    = htp_http_post_multipart($url, $fields, $file, self::TIMEOUT);
        $status = (int) (isset($res['status']) ? $res['status'] : 0);
        $reason = isset($res['reason']) ? (string) $res['reason'] : '';

        return array('ok' => ($status >= 200 && $status < 300), 'status' => $status, 'reason' => $reason);
    }

    /**
     * DATEI-LOSE Signal-Meldung (kein ZIP) - fuer das anonyme Update-Feedback ('fb',
     * "lief problemlos" / "Problem"). Traegt nur ein kleines Manifest mit Element/
     * Version/Urteil als strukturierte 'meta' (kein Host, keine IP -> anonym). Reine
     * geschlossene Rueckkopplung an radar, damit dort entschieden werden kann, ob eine
     * Version zentral gesperrt wird. Fail-soft.
     *
     * @param   array   $cfg   Konfiguration (Referenz; contrib_id ggf. gesetzt)
     * @param   string  $type  Signal-Typ ('fb')
     * @param   array   $meta  Strukturierte Felder (kind/element/type/version/verdict)
     *
     * @return array  ['ok'=>bool, 'status'=>int]
     */
    public static function reportSignal(&$cfg, $type, $meta = array())
    {
        $type = in_array($type, array('fb'), true) ? $type : 'fb';
        $id   = self::contribId($cfg);
        $note = is_array($meta) ? (string) json_encode($meta) : (string) $meta;
        $man  = self::buildManifest($type, $id, array(), $note);

        if (is_array($meta) && !empty($meta)) {
            $man['meta'] = $meta;
        }

        $sent = self::submitSignal($man);

        return array('ok' => !empty($sent['ok']), 'status' => isset($sent['status']) ? (int) $sent['status'] : 0);
    }

    /**
     * Signiertes (HMAC) Posten eines Manifests OHNE Datei-Anhang. Gleicher Endpunkt
     * und gleiche Felder wie submit(), nur ohne den ZIP-Teil (htp_http_post_multipart
     * laesst den Datei-Block bei leerem $file aus). Fail-soft.
     *
     * @param   array  $manifest  Manifest-Array
     *
     * @return array  ['ok'=>bool, 'status'=>int]
     */
    public static function submitSignal($manifest)
    {
        $json = json_encode($manifest);

        if (!is_string($json) || $json === '') {
            return array('ok' => false, 'status' => 0);
        }

        $key = (class_exists('HtpDefs') && defined('HtpDefs::SWARM_KEY')) ? HtpDefs::SWARM_KEY : '';
        $url = (class_exists('HtpDefs') && defined('HtpDefs::SWARM_URL')) ? HtpDefs::SWARM_URL : '';

        if ($key === '' || $url === '' || !function_exists('htp_http_post_multipart')) {
            return array('ok' => false, 'status' => 0);
        }

        $fields = array(
            'htp_report' => $json,
            'htp_sig'    => hash_hmac('sha256', $json, $key),
        );

        $res    = htp_http_post_multipart($url, $fields, array(), self::TIMEOUT);
        $status = (int) (isset($res['status']) ? $res['status'] : 0);
        $reason = isset($res['reason']) ? (string) $res['reason'] : '';

        return array('ok' => ($status >= 200 && $status < 300), 'status' => $status, 'reason' => $reason);
    }

    /**
     * Geschlossene Rueckkopplung: Wurde eine unserer Meldungen als Signatur
     * ausgespielt? Der Feed nennt akzeptierte Beitrags-IDs in 'ack'. Steht unsere
     * ID drin UND haben wir fuer diese Feed-Version noch nicht weggeklickt -> das
     * Dank-Banner anzeigen. READ-ONLY (kein Markieren!): das Banner bleibt bei
     * jedem Aufruf stehen, bis der Nutzer es per × bestaetigt (dismissThanks).
     * So kann der Dank nicht uebersehen werden. Reine Entscheidung: shouldThank().
     *
     * @param   array  $cfg  Konfiguration (Referenz)
     *
     * @return boolean  true = das Dank-Banner zeigen
     */
    public static function pendingThanks(&$cfg)
    {
        if (!class_exists('HtpDefs') || !method_exists('HtpDefs', 'malwareAck')) {
            return false;
        }

        $mine  = isset($cfg['swarm_contrib_id']) ? (string) $cfg['swarm_contrib_id'] : '';
        $ack   = HtpDefs::malwareAck();
        $ver   = (string) HtpDefs::malwareVersion();
        $acked = (isset($cfg['swarm_acked']) && is_array($cfg['swarm_acked'])) ? $cfg['swarm_acked'] : array();

        return self::shouldThank($mine, $ack, $ver, $acked);
    }

    /**
     * Der Nutzer hat das Dank-Banner per × geschlossen: die aktuelle Feed-Version
     * als "gedankt" verbuchen, damit das Banner nicht erneut erscheint (bis ein
     * spaeterer Feed wieder einen unserer Beitraege uebernimmt -> neue Version).
     *
     * @param   array  $cfg  Konfiguration (Referenz)
     *
     * @return void
     */
    public static function dismissThanks(&$cfg)
    {
        if (!class_exists('HtpDefs') || !method_exists('HtpDefs', 'malwareVersion')) {
            return;
        }

        $ver = (string) HtpDefs::malwareVersion();

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

        $acked = (isset($cfg['swarm_acked']) && is_array($cfg['swarm_acked'])) ? $cfg['swarm_acked'] : array();

        if (!in_array($ver, $acked, true)) {
            $acked[]            = $ver;
            $cfg['swarm_acked'] = $acked;

            if (class_exists('HtpConfig')) {
                HtpConfig::saveKeys(array('swarm_acked' => $acked));
            }
        }
    }

    /**
     * Reine Entscheidung (testbar): Dankes-Zeile zeigen?
     *   - eigene ID vorhanden UND in der Feed-ack-Liste,
     *   - und fuer diese Feed-Version noch nicht gedankt.
     *
     * @param   string  $mine    Eigene contrib_id
     * @param   array   $ack     Feed-ack-Liste (contrib_ids)
     * @param   string  $ver     Feed-Version
     * @param   array   $acked   Bereits gedankte Feed-Versionen
     *
     * @return boolean
     */
    public static function shouldThank($mine, $ack, $ver, $acked)
    {
        $mine = (string) $mine;
        $ver  = (string) $ver;

        if ($mine === '' || $ver === '' || !is_array($ack) || !is_array($acked)) {
            return false;
        }

        if (!in_array($mine, $ack, true)) {
            return false;
        }

        return !in_array($ver, $acked, true);
    }

    /**
     * Transiente Temp-Datei fuer den ZIP-Bau (Joomla-tmp). '' wenn nicht moeglich.
     *
     * @return string
     */
    private static function tmpFile()
    {
        $dir = function_exists('htp_tmp_path') ? (string) htp_tmp_path() : '';

        if ($dir === '' || !is_dir($dir) || !is_writable($dir)) {
            $dir = (string) sys_get_temp_dir();
        }

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

        return rtrim(str_replace('\\', '/', $dir), '/') . '/htp-swarm-' . substr(sha1(uniqid('', true)), 0, 12) . '.zip';
    }

    /**
     * Kurze Joomla-Version (versionsneutral).
     *
     * @return string
     */
    private static function jVersion()
    {
        if (class_exists('Joomla\\CMS\\Version')) {
            try {
                $v = new Joomla\CMS\Version();

                if (method_exists($v, 'getShortVersion')) {
                    return (string) $v->getShortVersion();
                }
            } catch (Exception $e) {
                // weiter
            }
        }

        if (class_exists('JVersion')) {
            try {
                $v = new JVersion();

                if (method_exists($v, 'getShortVersion')) {
                    return (string) $v->getShortVersion();
                }
            } catch (Exception $e) {
                // weiter
            }
        }

        return '';
    }
}
