<?php
/**
 * BitFire PHP based Firewall.
 * Author: BitFire (BitSlip6 company)
 * Distributed under the AGPL license: https://www.gnu.org/licenses/agpl-3.0.en.html
 * Please report issues to: https://github.com/bitslip6/bitfire/issues
 * 
 * all functions are called via api_call() from bitfire.php and all authentication 
 * is done there before calling any of these methods.
 */
namespace BitFire;

use ThreadFin\CacheStorage;
use ThreadFin\Effect;
use ThreadFin\FileData;
use ThreadFin\FileMod;
use BitFire\Config as CFG;
use Exception;
use RuntimeException;
use ThreadFin\CacheItem;
use ThreadFin\Hash_Config;
use ThreadFinDB\DB;

use const BitFire\Data\CODE_BOOK;
use const ThreadFin\DAY;

use function BitFire\Data\hydrate_log;
use function BitFire\Data\ip4_pos_to_loc;
use function BitFire\Data\ip4_uni_to_pos;
use function BitFire\Pure\ip_allow;
use function BitFire\Pure\ip_block;
use function BitFireBot\host_to_domain;
use function BitFireBot\hydrate_any_bot_file;
use function BitFireBot\ip_to_domain;
use function BitFirePlugin\file_name;
use function BitFirePlugin\file_ver;
use function BitFirePlugin\update_plugin_list;
use function BitFireSvr\add_ini_value;
use function BitFireSvr\authenticate_tech;
use function BitFireSvr\cms_root;
use function BitFireSvr\doc_root;
use function BitFireSvr\hash_file3;
use function BitFireSvr\parse_scan_config;
use function BitFireSvr\update_ini_value;
use function ThreadFin\array_map_value;
use function ThreadFin\contains;
use function ThreadFin\dbg;
use function ThreadFin\en_json;
use function ThreadFin\ends_with;
use function ThreadFin\file_recurse;
use function ThreadFin\partial_right as ƒixr;
use function ThreadFin\partial as ƒixl;
use function ThreadFin\random_str;
use function ThreadFin\un_json;
use function ThreadFin\debug;
use function ThreadFin\debugN;
use function ThreadFin\emerg;
use function ThreadFin\file_index;
use function ThreadFin\file_replace;
use function ThreadFin\get_hidden_file;
use function ThreadFin\HTTP\http2;
use function ThreadFin\HTTP\http_wait;
use function ThreadFin\HTTP\httpp;
use function ThreadFin\icontains;
use function ThreadFin\make_config_loader;
use function ThreadFin\path_normal;
use function ThreadFin\path_to_slug;
use function ThreadFin\starts_with;
use function ThreadFin\str_after_first;
use function ThreadFin\trace;
use function ThreadFin\validate_raw;
use function ThreadFin\ƒ_id;
use function ThreadFinDB\dump_database;

require_once \BitFire\WAF_SRC . "server.php";
require_once \BitFire\WAF_SRC . "cms.php";


const PAGE_DIRECTION_FORWARD = 'forward';
const PAGE_DIRECTION_BACKWARD = 'reverse';


const BF_EXT_MIME = [

    // Executable code, server side or runtime
    'php'   => 'text/x-php',
    'php3'  => 'text/x-php',
    'php4'  => 'text/x-php',
    'php5'  => 'text/x-php',
    'php7'  => 'text/x-php',
    'php8'  => 'text/x-php',
    'phtml' => 'text/html',
    'phps'  => 'application/x-httpd-php-source',
    'phar'  => 'application/octet-stream',
    'inc'   => 'text/plain',
    'tpl'   => 'text/plain',
    'cgi'   => 'application/x-httpd-cgi',
    'pl'    => 'text/x-perl',
    'py'    => 'text/x-python',
    'sh'    => 'application/x-sh',

    // Client side code and HTML
    'js'    => 'text/javascript',
    'mjs'   => 'text/javascript',
    'cjs'   => 'text/javascript',
    'html'  => 'text/html',
    'htm'   => 'text/html',
    'shtml' => 'text/html',
    'svg'   => 'image/svg+xml',
    'wasm'  => 'application/wasm',

    // Config and secrets
    'env'        => 'text/plain',
    'conf'       => 'text/plain',
    'config'     => 'text/plain',
    'yaml'       => 'text/yaml',
    'yml'        => 'text/yaml',
    'toml'       => 'application/toml',
    'properties' => 'text/plain',
    'json'       => 'application/json',
    'xml'        => 'application/xml',

    // Credentials, keys, certs
    'pem'      => 'application/x-pem-file',
    'key'      => 'application/pkcs8',
    'crt'      => 'application/x-x509-ca-cert',
    'cer'      => 'application/x-x509-ca-cert',
    'der'      => 'application/x-x509-ca-cert',
    'pfx'      => 'application/x-pkcs12',
    'p12'      => 'application/x-pkcs12',
    'pub'      => 'text/plain',
    'keystore' => 'application/octet-stream',

    // Databases and dumps
    'sql'     => 'application/sql',
    'dump'    => 'text/plain',
    'mysql'   => 'text/plain',
    'pgsql'   => 'text/plain',
    'sqlite'  => 'application/vnd.sqlite3',
    'sqlite3' => 'application/vnd.sqlite3',
    'db'      => 'application/octet-stream',
    'db3'     => 'application/octet-stream',
    'rdb'     => 'application/octet-stream',
    'ndjson'  => 'application/x-ndjson',
    'csv'     => 'text/csv',

    // Backups and temp
    'bak'  => 'application/octet-stream',
    'old'  => 'application/octet-stream',
    'orig' => 'application/octet-stream',
    'save' => 'application/octet-stream',
    'swp'  => 'application/octet-stream',
    'swo'  => 'application/octet-stream',
    'tmp'  => 'application/octet-stream',
    'temp' => 'application/octet-stream',
    'bk'   => 'application/octet-stream',
    '~'    => 'application/octet-stream',

    // Archives and compressed
    'zip' => 'application/zip',
    'tar' => 'application/x-tar',
    'tgz' => 'application/gzip',        // .tar.gz
    'gz'  => 'application/gzip',
    'bz2' => 'application/x-bzip2',
    'xz'  => 'application/x-xz',
    '7z'  => 'application/x-7z-compressed',
    'rar' => 'application/vnd.rar',
    'zst' => 'application/zstd',

    // Executables and binaries
    'exe'   => 'application/x-msdownload',
    'dll'   => 'application/x-msdownload',
    'so'    => 'application/octet-stream',
    'jar'   => 'application/java-archive',
    'class' => 'application/java-vm',
    'o'     => 'application/octet-stream',
    'bin'   => 'application/octet-stream',

    // Logs
    'log' => 'text/plain',
];

/**
 * make $dir_name if it does not exist, mode FILE_RW, 0755, etc
 * @impure
 * @return bool true if directory was newly created, or if it exists
 */
function make_dir(string $dir_name, int $mode) : bool {
    if (!file_exists(dirname($dir_name))) {
        return mkdir(dirname($dir_name), $mode, true);
    }
    return true;
}



function malware_clear_entry(string $path) : Effect {
    $effect = Effect::create();

    $malware_file = get_hidden_file("malware.jsonl");
    if (!file_exists($malware_file)) {
        return $effect->api(false, "no malware file");
    }
    $lines = file($malware_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    $n2  = path_normal($path);

    emerg("clear entry: $path");
    foreach ($lines as $line) {
        if (empty($line)) { continue; }
        $m = json_decode($line, true);
        if (!is_array($m)) { continue; }

        $n1 = path_normal($m['path']);

        emerg("check $n1 : $n2");
        if ($n1 !== $n2) {
            emerg("ADD");
            $out[] = $line;
        } else {
        emerg("SKIP");
        }
    }

    $effect->file(new FileMod($malware_file, implode("\n", $out) . "\n", FILE_RW, 0, false, true));
    return $effect;
}


/**
 * add an exception to exceptions.json
 * @pure
 * @API
 */
function rem_api_exception(\BitFire\Request $r) : Effect {
    assert(isset($r->post['uuid']) || isset($r->post['agent']), "uuid or agent is required");
    $uuid = $r->post['uuid']??NULL;

    // an effect and the exception to add
    $effect = Effect::create();

    // load exceptions from disk
    if (isset($r->post['uuid'])) {
        $file = get_hidden_file("exceptions.json");
        $exceptions = FileData::create($file)->read()->un_json();
        if ($exceptions === null) {
            debug("json read error in exceptions.json");
            return $effect->api(false, "exception file corrupted");
        } else {
            $removed = array_filter($exceptions(), function ($x) use ($uuid) {
                return ($x['uuid'] != $uuid);
            });
        }

        // nothing added, exception already exists
        if (count($removed) == count($exceptions())) {
            $effect->api(false, "exception does not exist");
        }
        // new exception added
        else if (count($removed) < count($exceptions())) {
            $effect->api(true, "exception removed");
            $effect->file(new FileMod($file, json_encode($removed, JSON_PRETTY_PRINT), FILE_RW));
        }
        // any other case
        else {
            $effect->api(false, "unable to remove exception from $file");
        }
    } else if (isset($r->post['agent'])) {

        $agent = $r->post['agent'];

        $file = get_hidden_file("browser_allow.json");
        $exceptions = FileData::create($file)->read()->un_json();
        if ($exceptions === null) {
            debug("json read error in exceptions.json");
            return $effect->api(false, "exception file corrupted");
        } 

        $data = $exceptions();
        $new = ["ip" => $data['ip'], "ua" => $data['ua']];

        $new['ip'] = array_filter($data['ip'], function ($key, $value) use ($agent) {
            return ($value != $agent);
        }, ARRAY_FILTER_USE_BOTH);
        $new['ua'] = array_filter($new['ua'], function ($key, $value) use ($agent) {
            return ($value != $agent);
        }, ARRAY_FILTER_USE_BOTH);


        // nothing added, exception already exists
        $new_count = count($new['ua']) + count($new['ip']);
        $old_count = count($data['ua']) + count($data['ip']);

        if ($new_count == $old_count) {
            $effect->api(false, "rule does not exist");
        }
        // new exception added
        else if ($new_count < $old_count) {
            $effect->api(true, "rule removed");
            $effect->file(new FileMod($file, json_encode($new, JSON_PRETTY_PRINT)));
        }
        // any other case
        else {
            $effect->api(false, "unable to remove rule from $file");
        }
    } else if (isset($r->post['file'])) {

        $file_path = get_hidden_file("file-whitelist.txt");
        $out = [];
        if (file_exists($file_path)) {
            $lines = file($file_path);
            foreach ($lines as $line) {
                if (trim($line) !== trim($r->post['file'])) {
                    $out[] = $line;
                }
            }
        }
        if (count($out) == count($lines)) {
            $effect->api(false, "file exception does not exist");
        }
        else {
            $effect->api(true, "file exception removed");
            file_put_contents($file_path, implode("", $out), LOCK_EX);
        }

    }

    // return the result
    return $effect;
}



/**
 * Add an exception to exceptions.json
 * Side effects: may schedule a FileMod write to exceptions.json via Effect
 */
function add_api_exception(\BitFire\Request $r) : Effect {
    $effect = Effect::create();

    // validate input: code must be provided and numeric
    if (!isset($r->post['code']) || !is_numeric($r->post['code'])) {
        return $effect->api(false, "block code not sent, numeric code is required");
    }
    $code = (int)$r->post['code'];

    // special handling of added bot validation exceptions
    if (in_array($code, [24002, 25001], true)) {
        $r->post['action'] = "pass_ua";
        $r->post['allow']  = 1;
        $effect->chain(bot_action($r));
        return $effect->api(true, "Network Authentication exception added for user-agent " . 
            htmlspecialchars($r->post['ua']) . 
            " consider using bot-control for this");
    }

    // build exception object from type
    $type = $r->post['type'] ?? null;
    $ex   = null;

    $message = "Exception added code block code: $code";
    if ($type === "code") {
        $ex = new \BitFire\Exception($code, random_str(8), null, null, null);
    } else if ($type === "url") {
        $raw_url = (string)($r->post['url'] ?? "");
        if ($raw_url === "") {
            return $effect->api(false, "url path not sent, required when type=url");
        }
        // normalize to a canonical path-only representation
        $path = parse_url($raw_url, PHP_URL_PATH) ?? "/";
        if ($path !== "/" && substr($path, -1) === "/") {
            $path = rtrim($path, "/");
        }
        if ($path === "") { $path = "/"; }

        $ex = new \BitFire\Exception($code, random_str(8), null, $path, null);
        $message = "Exception added for URL $path, block code: $code";
    } else if ($type === "ip") {
        $raw_url = (string)($r->post['ip'] ?? "");
        if ($raw_url === "") {
            return $effect->api(false, "ip not sent, required when type=ip");
        }
        // normalize to a canonical path-only representation
        $path = parse_url($raw_url, PHP_URL_PATH) ?? "/";
        if ($path !== "/" && substr($path, -1) === "/") {
            $path = rtrim($path, "/");
        }
        if ($path === "") { $path = "/"; }

        $ex = new \BitFire\Exception($code, random_str(8), null, $path, null);
        $message = "Exception added for IP $path, block code: $code";
    }
    else {
        return $effect->api(false, "invalid or missing type, expected 'code' or 'url'");
    }

    // load existing exceptions from disk, tolerate missing or invalid JSON
    $file = get_hidden_file("exceptions.json");
    $fd   = FileData::create($file)->read();

    // if un_json() fails internally, ensure we fall back to an empty array
    try {
        $exceptions_mapped = $fd->un_json()->map('\BitFire\map_exception');
        $existing          = is_callable($exceptions_mapped) ? $exceptions_mapped() : (array)$exceptions_mapped;
    } catch (\Throwable $e) {
        $existing = [];
    }

    // add new exception (function expected to deduplicate)
    $orig_count = count($existing);
    $updated    = add_exception_to_list($ex, $existing);
    $new_count  = is_array($updated) ? count($updated) : $orig_count;

    // decide response
    if ($new_count === $orig_count) {
        return $effect->api(false, "exception already exists");
    }

    if ($new_count > $orig_count) {
        $json = json_encode($updated, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
        if ($json === false) {
            return $effect->api(false, "failed to encode exceptions to JSON");
        }
        // ensure newline at EOF
        if ($json === "" || substr($json, -1) !== "\n") { $json .= "\n"; }

        // queue file write
        $effect->file(new FileMod($file, $json, FILE_RW));
        return $effect->api(true, $message);
    }

    // any other case
    return $effect->api(false, "unable to add exception to {$file}");
}




/**
 * @pure
 * @param Request $r 
 * @return void 
 */
function download(\BitFire\Request $r) : Effect {
    assert(isset($r->get["filename"]), "filename is required");

	$effect = Effect::create();
    $root = \BitFireSvr\cms_root() . "/";
	$filename = $r->get['filename'];
    if (!starts_with($filename, $root)) {
        $path = $root . trim($filename,"/");
    } else {
        $path = '/' . $filename;
    }

    $extension = pathinfo($filename, PATHINFO_EXTENSION);
    $extension = (is_string($extension)) ? strtolower($extension) : '';
    $type = BF_EXT_MIME[$extension] ?? 'application/octet-stream';
    $effect->header('Content-Type', $type);
    // FILE NAME GUARD
    if (contains($filename, RESTRICTED_FILES)) {
        return $effect->api(false, "invalid file.", ["filename" => $filename]);
    }
    if (is_link($filename)) {
        return $effect->api(false, "symlinks can not be downloaded.", ["filename" => $filename]);
    }
    // load data
    $file = FileData::create($path);
    if (!$file->exists) {
        return $effect->api(false, "no file.", ["filename" => $path]);
    }
    $data = $file->raw();


    // decide disposition
    $base   = basename($filename);
    $direct = filter_var($r->get['direct'] ?? null, FILTER_VALIDATE_BOOLEAN);
    $disp   = $direct ? 'inline' : 'attachment';

    $effect->header("content-description", "File Transfer")
        ->header('Content-Disposition', $disp . '; filename="' . $base . '"')
        ->header('Expires', '0')
        ->header('Cache-Control', 'must-revalidate')
        ->header('X-Content-Type-Options', 'nosniff')
        ->header('Pragma', 'private')
        ->header('Content-Length', (string)strlen($data));

    $effect->out($data);
    return $effect;
}


/**
 * 
 * @param array $ips - list of ips to convert
 * @param array $domains - list of already converted domains format: domain.com:ip1.ip2.ip3
 * @return string 
 */
function ips_to_domains(array $ips, array $domains = []) : string {
    $cache = [];
    // cache already found domains

    foreach(array_keys($ips) as $ip) {
        if (isset($cache[$ip])) { continue; }
        $cache[$ip] = true;
        $domain_list[] = ip_to_domain($ip);
    }

    return implode(",", array_unique($domain_list));
}





function bot_action(\BitFire\Request $request) : Effect {

    $effect = Effect::create();
    // GUARDS...
    if (isset($request->post['ip'])) {
        if (empty($ip) && !filter_var($request->post['ip'], FILTER_VALIDATE_IP)) {
            return $effect->api(false, "invalid IP address");
        }
    }
    $action = $request->post["action"]??"unknown";
    $setting = intval($request->post['allow']??1) ?? 0;
    $ip = $request->post['ip'] ?? "";
     
    // read the browser file
    $allow_file = FileData::create(get_hidden_file("browser_allow.json"))->read()->un_json();
    $file_data = $allow_file->lines;

    if ($action === "pass_ip") {
        $allow_effect = ip_allow($ip);
        if ($allow_effect->read_status() == STATUS_EEXIST) {
            $allow_effect->api(false, "$ip is already allowed");
        } else {
            $allow_effect->api(true, "$ip unblocked"); // remove the IP block
        }
        return $allow_effect;
    }
    else if ($action === "block_ip") {
        if($ip == $_SERVER['SERVER_ADDR'] && intval($setting) != 1) {
            $effect->api(false, "Can not add own server IP to the block list list");
        } else {
            $effect = ip_block($ip, NULL, DAY * 365, NULL, "Manual IP Block for host: (" . (gethostbyaddr($ip)?? "unknown domain") . ")");
            $t0 = microtime(true);
            $hostname = gethostbyaddr($ip)?? "unknown domain";
            $t1 = microtime(true);
            $len = round((($t1 - $t0)), 3);
            if ($effect->read_status() == STATUS_EEXIST) {
                $effect->api(false, "$ip ($hostname) is already blocked (dns resolution $len sec)");
            } else {
                $effect->api(true, "$ip ($hostname) blocked for 365 days (dns resolution $len sec)");
            }
        }
        return $effect;
    }

    // if we are editing browser settings (usually "bot" browsers)
    if (in_array($action, ["pass_ua", "block_ua"])) {

        $setting = contains($action, "pass") ? 1 : 0;
        $value = $request->post['ua']??"";
        $value = ($value == "none") ? "" : $value;
        if (empty($value) || strlen($value) < 4) {
            return $effect->api(false, "Minimum 3 character User-Agent is required");
        }
        // don't allow duplicates...
        if ($request->post['new']??0) {
            if (is_array($file_data) && isset($file_data['ua'][$value])) {
                return $effect->api(false, "User-Agent ($value) already exists", ['error' => "UA $value already exists", "data" => $allow_file->lines]);
            }
        }
        $allow_file->lines['ua'][$value] = $setting;
        $content = json_encode($allow_file->lines, JSON_PRETTY_PRINT);
        // return new effect to update file
        $effect->file(new FileMod(get_hidden_file("browser_allow.json"), $content, FILE_RW, 0, 0, true));
        $note = ($setting == 1) ? "allowed" : "blocked";
        $effect->api(true, "browser " . $request->post['ua'] . " added to the $note list", ['ua' => $value, 'setting' => $setting, "data" => $allow_file->lines]);

        return $effect;
    }

    // purge old bots...
    if ($request->post["action"] == "purge") {
        $bot_dir = get_hidden_file("bots");
        $bot_files = glob("{$bot_dir}/*.json");

        array_walk($bot_files, function($file) {
            $bot = unserialize(file_get_contents($file));
            // remove broken files
            if (!$bot) { unlink($file); return; }
            // don't remove known bots
            if ($bot->valid)  { return; }
            if (!icontains($bot->vendor, "unknown"))  { return; }

            // remove old bots
            if (filemtime($file) + (DAY * 7) < time()) {
                unlink($file);
            }

            // remove bots with no or 1 hit
            if ($bot->hit + $bot->miss <= 1) {
                unlink($file);
            }

        });

        return $effect->api(true, "purged old bots", ["count" => count($bot_files)]);
    }



    $id = intval($request->post["bot"]??0);
    $ip = filter_var($request->post["ip"]??"", FILTER_VALIDATE_IP);

    $lookup = [];
    $bot_dir = get_hidden_file("bots");
    $info_file = "{$bot_dir}/{$id}.js";
    if ($request->post["action"] == "rm") {
        $effect->unlink($info_file);
        $effect->api(true, "bot remove", ["id" => $id]);
        return $effect;
    }
    $fd = FileData::create($info_file);
    if ($fd->exists) {
        trace("BOT_RM");
        $bot_data = hydrate_any_bot_file($info_file);
        if (!$bot_data) {
            $effect->unlink($info_file);
            return $effect->api(false, "unable to load bot file $id");
        }
    } else {
        return $effect->api(false, "bot file $id -> $info_file does not exist");
    }

    if (! $bot_data instanceof \BitFire\BotSimpleInfo) {
        return $effect->api(false, "bot data corruption");
    }
    if ($request->post["action"] == "no") {
        trace("BOT_NO");
        $bot_data->manual_mode = BOT_ALLOW_NONE;
        $bot_data->configured = true;
        $effect->api(true, "bot block all", ["id" => $id, "domain" => $bot_data->domain, "mode" => $bot_data->manual_mode]);
    }
    else if ($request->post["action"] == "any") {
        trace("BOT_ANY");
        $bot_data->manual_mode = BOT_ALLOW_ANY;
        $bot_data->configured = true;
        $effect->api(true, "bot allow any", ["id" => $id, "domain" => $bot_data->domain, "mode" => $bot_data->manual_mode]);
    }
    else if (in_array($request->post["action"], ["known", "unknown"])) {
        trace("BOT_KNOWN");
        $bot_data->valid = $request->post["action"] == "known";
        $bot_data->configured = true;
        //if (empty($bot_data->vendor) || !icontains($bot_data->vendor, "unknown")) {
            $bot_data->vendor = $request->post['action'];//($bot_data->vendor == "Unknown Bot." && $bot_data->valid) ? $bot_data->name : $bot_data->vendor;
        //}
        $effect->api(true, "bot classify known", ["id" => $id, "domain" => $bot_data->domain, "valid" => $bot_data->valid]);
    }
    else if ($request->post["action"] == "junk") {
        trace("BOT_JUNK");
        $bot_data->manual_mode = BOT_ALLOW_NONE;
        $bot_data->valid = 0;
        $bot_data->configured = true;
        $bot_data->vendor = "junk";
        $effect->api(true, "bot classify known", ["id" => $id, "domain" => $bot_data->domain, "valid" => $bot_data->valid]);
    }

    else if ($request->post["action"] == "auth") {
        $bot_data->manual_mode = BOT_ALLOW_AUTH;
        $bot_data->configured = true;
        trace("BOT_AUTH");
        $domain_list = [];//explode(",", $bot_data->domain);

        $bot_data->domain = ips_to_domains($bot_data->ips);
        debug("allowed domain [%s]", $bot_data->domain);
        $effect->api(true, "bot auth", ["id" => $id, "domain" => $bot_data->domain, "mode" => $bot_data->manual_mode]);
    }
    else if ($request->post["action"] == "rest") {
        $bot_data->manual_mode = BOT_ALLOW_RESTRICT;
        $bot_data->configured = true;
        trace("BOT_REST");
        $bot_data->domain = ips_to_domains($bot_data->ips);
        debug("allowed domain [%s]", $bot_data->domain);
        $effect->api(true, "bot auth", ["id" => $id, "domain" => $bot_data->domain, "mode" => $bot_data->manual_mode]);
    }
    else if ($request->post["action"] == "lock_ip") {
        $effect = ip_block($ip, NULL, DAY * 365, NULL, "Manual IP Block for host: (" . (gethostbyaddr($ip)?? "unknown domain") . ")");
        $hostname = gethostbyaddr($ip)?? "unknown domain";
        $effect->api(true, "$ip ($hostname) blocked for 365 days");
    }
    else if ($request->post["action"] == "unlock_ip") {
        $effect = ip_allow($ip);
        if ($effect->read_status() != STATUS_OK) {
            return $effect->api(false, "unable to unblock $ip");
        }
        return $effect->api(true, "$ip unblocked"); // remove the IP block
    }
    else if ($request->post["action"] == "ignore_ip") {
        unset($bot_data->ips[$ip]);
        debug("removed ip [%s]", $ip);
        $effect->api(true, "bot ip $ip ignored", ["id" => $id, "ip" => $ip, "message" => "$ip removed"]);
    }
    // update the bot access file, but keep the modification time the same
    $mtime = filemtime($info_file);
    $effect->file(new FileMod($info_file, json_encode($bot_data, JSON_PRETTY_PRINT), FILE_RW, $mtime, false, true));
    return $effect;
}



/**
 * get all ip info detail from bitfire servers
 * @param Request $request 
 * @return Effect 
 */
function get_ip_info(\BitFire\Request $request) : Effect {
    $z0 = microtime(true);
    $effect = Effect::create();
    $id = intval($request->post["bot_id"]??0);
    if (empty($id)) {
        return $effect->api(false, "invalid bot id");
    }

    $bot = hydrate_any_bot_file(get_hidden_file("bots/{$id}.js"));

    $z1 = microtime(true);
    //$response = http2("POST", "https://bitfire.co/ip_info.php", base64_encode(json_encode($bot_list->ips)));
    //$ip_info = json_decode($response->content);

    $long_names = json_decode(file_get_contents(WAF_ROOT . "data/country_name.json"), true);

    $z2 = microtime(true);
    static $dom_cache = [];
    $ip_info = array_map_value(function($ip, $classification) use ($long_names, $id, &$dom_cache) {
        $split = explode(".", $ip);
        $info = ['city' => 'unknown', 'id' => $id, 'country' => 'unknown', 'ip' => $ip, 'domain' => 'no reverse dns', 'category' => '', 't1' => microtime(true)];
        if (count($split) > 2) {
            $part_ip = $split[0] . "." . $split[1] . "." . $split[2];
            if (!isset($dom_cache[$part_ip])) {
                $domain = host_to_domain(gethostbyaddr($ip));
                $dom_cache[$part_ip] = $domain ?: "no dns entry";
            }
            $info['domain'] = $dom_cache[$part_ip];
            $loc = ip4_pos_to_loc(ip4_uni_to_pos(\BitFire\Data\ip4_to_uni($ip)), $long_names);
            $info['city'] = $loc->city;
            $info['country'] = $loc->country;
        }
        foreach (REQ_NAMES as $name => $mask) {
            if ($classification & $mask) {
                $info['category'] .= "<span class='badge bg-".REQ_COLOR[$name]."'>$name</span> ";
            }
        }

        $info['t2'] = microtime(true) - $info['t1'];
        return $info;
    }, $bot->ips);

    $z3 = microtime(true);
    //return $effect->api(true, "success", [$z3, $z2, $z1, $z0]);
    return $effect->api(true, "success", $ip_info);
}




/**
 * replace an array value in the ini file
 * @param Request $request 
 * @return Effect 
 */
function replace_array_value(\BitFire\Request $request) : Effect {
    $effect = Effect::create();
    $name = $request->post["param"];

    // new scan start init code. this is only called from the malware scanner....
    http2("POST", "https://bitfire.co/malware.php?src=".$_SERVER['HTTP_HOST'], ['scan' => 'new'], ['timeout' => 3000]);
    $effect->unlink(get_hidden_file("file.index"));
    $effect->unlink(get_hidden_file("malware.jsonl"));

    $value_list = explode(",", $request->post["value"]);
    if (is_array($value_list)) {
        $effect = update_ini_value($name, $value_list)->api(true, "array list updated");
    }
 
    return $effect;
}



/**
 * 
 * @param Request $request  - options: offset - can always be 0, batch_sz - 0 = auto detect, load_scan = just return contents of malware.jsonl
 * @return Effect 
 * @throws RuntimeException 
 * @throws Exception 
 */
function general_scan(\BitFire\Request $request) : Effect {
    require_once WAF_SRC . "diff.php";
    require_once WAF_SRC . "cms.php";
    $root = \BitFireSvr\cms_root();
    ini_set("max_execution_time", 245);
    $offset = intval($request->post['offset']??'-1');
    $user_sz = intval($request->post['batch_sz']??'0');

    $index_file = get_hidden_file("file.index");
    $malware_file = get_hidden_file("malware.jsonl");
    $config = parse_scan_config(CFG::arr("malware_config"));

    // first page hit before the scan. send plugin list so we can ensure that all hashes are known 
    if (intval($request->post['load_scan']??'0') == 1) {
        $offset = 0;

        $json_array = json_parse_lines($malware_file);

        $data = ["basename" => basename($root), "complete" => 1, "found" => count($json_array), "dir" => $root,
            "batch_size" => 0, "skip_count" => 0, "file_count" => 0,
            "data" => base64_encode(json_encode($json_array))];

        $effect = Effect::create()->api(true,
            "hashed " . count($json_array) . " skipped " . '0' . " mem: " . memory_get_peak_usage(), $data);

        $success = true;
        $note = "Unable to update plugin list. Missing hashes will be marked as unknown.";
        $info_file = get_hidden_file("plugin_info.json");
        $skip_update = (intval($request->post['no_update']??'0') == 1);
        $mtime = file_exists($info_file) ? filemtime($info_file) : 0;

        // don't update the plugin data more than 1x every 10 min
        if (!$skip_update && function_exists("get_plugins") && ($mtime < (time() - 600))) {
            $url = HASH . "hash_plugins.php?src=".$_SERVER['HTTP_HOST'];

            $r = http2("POST", $url, base64_encode(json_encode(get_plugins())), ['timeout' => 15000]);
            // we should always hit this path if everything is okay
            if ($r->success && $r->len > 10) {
                $result = json_decode($r->content, true);
                if (is_array($result)) {
                    $effect->file(new FileMod($info_file, json_encode($result, JSON_PRETTY_PRINT), FILE_RW, 0, false, false));
                    $note = "Server hashes updated";
                    $data['data2'] = $result;
                } else {
                    $success = false;
                    $note = "Unable to update server hashes\n";
                }
            }
        }

        return $effect->api($success, $note, $data);
    }

    $total = 0;
    // we are doing a fresh scan, create a new index file
    if ($offset == 0 || !file_exists($index_file)) {
        $reg_ex = ($config->non_php == 1) ? '/.*\.(php|js|html|sql|dump|db|zip|tar|tgz|gz|bz2|bak|old|orig|save|tmp|temp|bk|env|ini|conf|yml|yaml|properties|pem|key|p12|pfx|crt|cer|der)$/' : '/.*\.(php|js)$/';
        $ctx = fopen($index_file, "w+");
        // $write_fn = ƒixl('fwrite', $ctx);
        $core_path = get_hidden_file("bots");
        file_index($root, $reg_ex, function($file) use ($ctx, &$total, $core_path) {
            if (starts_with($file, $core_path)) { return; } // ignore our bot data
            $total++;
            fwrite($ctx, path_normal($file) . "\n");
        });
        fclose($ctx);
        // since we are doing a new scan, remove any old malware file
        if (file_exists($malware_file)) {
            unlink($malware_file);
        }
    }

    // for reading php files
    if (defined("BitFirePRO")) { stream_wrapper_restore("file"); }

    $ini_limit = ini_get('memory_limit');
    $memory_mb = 22; // default to low reasonable value 
    if (preg_match('/^(\d+)(.)$/', $ini_limit, $matches)) {
        if ($matches[1] == 'M') {
            $memory_mb = intval($matches[1]);
        }
        else if ($matches[2] == 'G') {
            $memory_mb = intval($matches[1])*1024;
        }
    }
    $memory_mb = max($memory_mb, 96); // target 96MB - (batch size 240)

    $batch_size = ($user_sz > 0) ? $user_sz : min(200, floor($memory_mb / .6));
    $list = scan_filesystem($index_file, $offset, $batch_size, parse_scan_config(CFG::arr("malware_config")));


    $lines_to_save = array_map('json_encode', $list->_list);


    $effect = Effect::create()->api(true, "hashed " . $list->num_scanned . " skipped " . $list->num_skipped . " mem: " . memory_get_peak_usage(), array("basename" => basename($root), "total" => $total, "complete" => $list->complete, "found" => count($list), "dir" => $root, "batch_size" => $batch_size, "skip_count" => $list->num_skipped, "file_count" => $list->num_scanned, "data" => base64_encode(json_encode(array_values($list->_list)))));

    // if the scan is complete, remove the index file
    if ($list->complete) {
        $effect->unlink($index_file);
    }

    if (count($list) > 0) {
        http2("POST", "https://bitfire.co/malware.php?src=".$_SERVER['HTTP_HOST'], base64_encode(json_encode($list->_list)), ['timeout' => 3000]);
    }
    $effect->file(new FileMod($malware_file, join("\n", $lines_to_save) . "\n", FILE_RW, 0, true));

    return $effect;
}




/**
 * need to test these 2 enrichment functions
 * @param mixed $ver 
 * @param mixed $dir_without_plugin_name 
 * @param mixed $hash_slice 
 * @return array<array-key, mixed> 
 */
function batch_enrich($hash_slice, ?ScanConfig $config = null) {
    assert(count($hash_slice) < 24, "curl multi can only handle 24 at a time");

    if ($config == null) {
        $config = parse_scan_config(CFG::arr("malware_config"));
    }

    //debug("batch_enrich (%s) (%s) (%s)", $ver, $dir_without_plugin_name, json_encode($hash_slice));

    if (function_exists("curl_multi_init")) {
        $mh = curl_multi_init();
    } else {
        $mh = null;
    }
    $enrich_fn = ƒixl('\BitFire\enrich_hashes', $mh);

    // WTF DOES THIS NOT WORK? SEE debug.log [updraftplus]
    $enriched1 = array_map($enrich_fn, $hash_slice);
    //debug("enriched1 : (%s)", json_encode($enriched1));

    // debug("waiting... (%s)", json_encode($hash_slice));
    if (!empty($mh)) {
        http_wait($mh);
    }
    $enriched2 = array_map(ƒixr('\BitFire\enrich_hashes2', $mh, $config), $enriched1);
    //debug("enriched2 : (%s)", json_encode($enriched2));

    if (function_exists("curl_multi_close")) {
        curl_multi_close($mh);
    }

    return $enriched2;
}




/**
 * download a BitFire release
 * @param string $version 
 * @return Effect 
 */
function download_tag(string $version, string $dest) : Effect {
    // download the archive TODO: check checksum
    $link = "https://github.com/bitslip6/bitfire/archive/refs/tags/{$version}.tar.gz";
    $resp_data = http2("GET", $link, "");
    $check_data = http2("GET", "https://bitfire.co/releases/{$version}.md5");
    $test_md5 = md5($resp_data->content);
    // checksum mismatch
    if ($test_md5 !== $check_data->content) {
        return Effect::create()->status(STATUS_ECOM);
    }
    return Effect::create()->status(STATUS_OK)->file(new FileMod($dest, $resp_data->content));
}

// only called for standalone installs, not plugins
function upgrade(\BitFire\Request $request) : Effect {
    $v = preg_replace("/[^0-9\.]/", "", $request->post['ver']);
    if (\version_compare($v, BITFIRE_SYM_VER, '<')) { 
        debug("version not current [%s]", $v);
        return Effect::create()->api(false, "version is not current");
    }

    // ensure that all files are writeable
    file_recurse(\BitFire\WAF_ROOT, function ($x) {
        if (!is_writeable($x)) { 
            return Effect::create()->api(false, "unable to upgrade: $x is not writeable");
        }
    });

    // allow php file manipulation
    stream_wrapper_restore("file");

    // download and verify no errors
    $dest = \BitFire\WAF_ROOT."data/{$v}.tar.gz";
    $e = download_tag($v, $dest);
    $e->run();
    if ($e->num_errors() > 0) {
        return Effect::create()->api(false, "error downloading and saving release", $e->read_errors());
    }
    

    //  extract archive
    $target = \BitFire\WAF_ROOT . "data";
    require_once \BitFire\WAF_SRC."tar.php";
    $success = \ThreadFin\tar_extract($dest, $target) ? "success" : "failure";
    

    // replace files
    file_recurse(\BitFire\WAF_ROOT."data/bitfire-{$v}", function (string $x) use ($v) {
        $base = basename($x);
        if (is_file($x) && ends_with($x, "config.ini")) {
            $root = str_replace(\BitFire\WAF_ROOT."data/bitfire-{$v}/", "", $x);
            if (!rename($x, \BitFire\WAF_ROOT . $root)) { debug("unable to rename [%s] - %s", $x, $root); }
        }
    });

    $cwd = getcwd();
    return $effect->api($success, "upgraded with [$dest] in [$cwd]");
}

 

function delete(\BitFire\Request $request) : Effect
{
    $effect = Effect::create();

    $raw    = (string)($request->post['value'] ?? '');
    $name   = (string)($request->post['name'] ?? '');
    $unique = (string)($request->post['id'] ?? '');

    if ($raw === '') {
        return $effect->api(false, "no file to delete");
    }

    // Reject obvious bad inputs early
    if (strpos($raw, "\0") !== false) {
        return $effect->api(false, "invalid path (NUL)");
    }
    if (strpos($raw, '..') !== false) {
        return $effect->api(false, "refusing to delete relative path [$raw]");
    }

    // Normalize paths
    $root = rtrim(str_replace('\\','/', \BitFireSvr\cms_root()), '/');
    $rootReal = realpath($root);
    if ($rootReal === false) {
        return $effect->api(false, "invalid CMS root");
    }
    $rootReal = rtrim(str_replace('\\','/', $rootReal), '/');

    $p = str_replace('\\','/', $raw);
    $isAbs = (bool)preg_match('~^(?:/|[A-Za-z]:[\\/]|\\\\\\\\)~', $p); // *nix, Windows drive, UNC

    // Candidate source path (may not exist yet)
    $candidate = $isAbs ? $p : ($rootReal . '/' . ltrim($p, '/'));
    $candidate = preg_replace('~//+~', '/', $candidate);

    // Resolve the directory; file may not exist so we realpath() the parent dir
    $parent = dirname($candidate);
    $base   = basename($candidate);
    // don't delete likely auto pre load files
    if ($base == "bitfire-waf.php" || $base == "wordpress-waf.php") {
        return $effect->api(false, "refusing to delete protected file [$base]");
    }

    $parentReal = realpath($parent);
    if ($parentReal === false) {
        return $effect->api(false, "refusing to delete: parent dir missing [$parent]");
    }
    $parentReal = rtrim(str_replace('\\','/', $parentReal), '/');

    // Build the resolved source path
    $src = $parentReal . '/' . $base;

    // Must live inside $root
    if (strpos($src . '/', $rootReal . '/') !== 0) {
        return $effect->api(false, "refusing to delete outside root [$src]");
    }

    // Existence, type, symlink checks
    if (!file_exists($src)) {
        return $effect->api(false, "refusing to delete non-existent file [$src] ($raw) ($name)");
    }
    if (is_link($src)) {
        return $effect->api(false, "refusing to delete symlink [$src]");
    }
    if (!is_file($src)) {
        return $effect->api(false, "refusing to delete non-regular file [$src]");
    }

    // Quarantine target: keep relative path under quarantine, add .bak.<ts>.<rand>
    $rel = ltrim(substr($src, strlen($rootReal)), '/');
    $suffix   = '.bak.' . date('YmdHis') . '.' . random_int(10000, 99999);
    $qRoot    = get_hidden_file("quarantine") . '/';
    $qTarget  = $qRoot . $rel . $suffix;
    $qDir     = dirname($qTarget);

    // Ensure quarantine directory exists
    if (!is_dir($qDir)) {
        if (function_exists('make_dir')) {
            make_dir($qDir, defined('FILE_EX') ? FILE_EX : 0775);
        } else {
            @mkdir($qDir, 0775, true);
        }
    }

    // Try to quarantine (rename); relax perms if needed
    if (!is_writable($src)) {
        @chmod($src, defined('FILE_RW') ? FILE_RW : 0640);
    }

    if (@rename($src, $qTarget)) {
        debug("quarantined [%s] -> [%s]", $src, $qTarget);
        $effect = malware_clear_entry($src);
        return $effect->api(true, "quarantined to {$qTarget}", ["path" => $raw, "unique" => $unique]);
    }

    // fallback delete
    $deleted = @unlink($src);
    if ($deleted) {
        debug("deleted [%s] (could not quarantine)", $src);
        return $effect->api(true, "file deleted", ["path" => $raw, "unique" => $unique]);
    }

    $effect = malware_clear_entry($src);

    // Fallback: delete if we couldn't quarantine
    $deleted = @unlink($src);
    if ($deleted) {
        debug("deleted [%s] (could not quarantine)", $src);
        return $effect->api(true, "file deleted", ["path" => $raw, "unique" => $unique]);
    }

    debug("permission error deleting [%s]", $src);
    return $effect->api(false, "delete permissions error '$src'");

}





function set_pass(\BitFire\Request $request) : Effect {
    $effect = Effect::create();
    debug("save pass");
    if (strlen($request->post['pass1']??'') < 8) {
        return $effect->api(false, "password is too short");
    }
    $p1 = hash("sha3-256", $request->post['pass1']??'');
    debug("pass sha3-256 %s ", $p1);

    $config_file = make_config_loader()->run()->read_out();
    $pass = file_replace($config_file, "password = 'default'", "password = '$p1'")->run()->num_errors() == 0;
    exit(($pass) ? "success" : "unable to write config file to: $config_file");
}


// TODO: dead code. might be useful in the future...
function remove_list_elm(\BitFire\Request $request) : Effect {
    $effect = Effect::create();
    // guards
    if (!isset($request->post['config_name'])) { return $effect->api(false, "missing config parameter"); }
    if (!isset($request->post['config_value'])) { return $effect->api(false, "missing config value parameter"); }
    if (!isset($request->post['index'])) { return $effect->api(false, "missing index parameter"); }

    $value = substr($request->post['config_value'], 0, 80);
    $name = $request->post['config_name'];
    if (!in_array($name, \BitFireSvr\CONFIG_KEY_NAMES)) { return $effect->api(false, "unknown parameter name"); }

    $orig = CFG::arr($name);
    foreach ($orig as $k => $v) {
        if ($v === $value) {
            unset($orig[$k]);
        }
    }
    $effect = update_ini_value($name, $orig);
    if ($effect->read_status() != STATUS_OK) {
        return $effect->api(false, "error updating ini status: " . $effect->read_status());
    }

    // SUCCESS!
    return $effect->api(true, "updated");
}

// modify to use FileData
// FIX RESPONSE: 
function add_list_elm(\BitFire\Request $request) : Effect {
    $effect = Effect::create();

    // guards
    if (!isset($request->post['config_name'])) { return $effect->api(false, "missing config parameter"); }
    if (!isset($request->post['config_value'])) { return $effect->api(false, "missing config value parameter"); }

    $value = substr($request->post['config_value'], 0, 80);
    $name = $request->post['config_name'];
    if (!in_array($name, \BitFireSvr\CONFIG_KEY_NAMES)) { return $effect->api(false, "unknown parameter name"); }

    $effect = add_ini_value($name, $value)->api(true, "config.ini updated");
    return $effect;
}

// install always on protection (auto_prepend_file)
function install(?\BitFire\Request $request = null) : Effect {
    // CALL SERVER AND KEEP THIS CHECK HERE
    if (isset($_SERVER['IS_WPE'])) {
        $note = "WPEngine has a restriction which prevents that here.  Please go to WordPress plugin page and disable then re-enable this plugin to activate always-on.";
        return Effect::create()->exit(true, STATUS_FAIL, $note)->api(false, $note);
    }

    $effect = \BitFireSvr\install();
    return $effect;
}

// uninstall always on protection (auto_prepend_file)
function uninstall(\BitFire\Request $request) : Effect {

    $root = doc_root();
    $file = "$root/".ini_get("user_ini.filename");
    $effect = Effect::create();
    $status = ((\BitFireSvr\install_file($file, "")) ? "success" : "error");
    $note = "Unable to remove BitFire from auto start.  check permissions on file [$file]";
    if ($status) {
        // install a lock file to prevent auto_prepend from being uninstalled for ?5 min
        $effect->file(new FileMod(\BitFire\WAF_ROOT . "uninstall_lock", "locked", 0, time() + intval(ini_get("user_ini.cache_ttl"))));
    
        $cms_root = cms_root();
        $waf_load = "$cms_root/wordfence-waf.php";
        // auto load file exists
        if (file_exists($waf_load)) {
            $c = file_get_contents($waf_load);
            // only remove it if this is a bitfire emulation
            if (stristr($c, "bitfire")) {
                $effect->unlink($waf_load);
            }
        }

        $waf_load = "$cms_root/bitfire-waf.php";
        // auto load file exists
        if (file_exists($waf_load)) {
            $c = file_get_contents($waf_load);
            // only remove it if this is a bitfire emulation
            if (stristr($c, "bitfire")) {
                $effect->unlink($waf_load);
            }
        }

        $path = realpath(\BitFire\WAF_ROOT."startup.php"); // duplicated from install_file. TODO: make this a function
        $note = ($status == "success") ? "BitFire was removed from auto start. This may take up to " . ini_get("user_ini.cache_ttl") . " seconds to take effect (cache clear time)" :
            "Unable to remove BitFire from auto start.  check permissions on file [$file]";
        $effect = Effect::create();
        $effect->out(json_encode(array('status' => $status, 'note' => $note, 'method' => 'user.ini', 'path' => $path)));
    }
    return $effect;
}


function toggle_config_value(\BitFire\Request $request) : Effect {

    $config_file = make_config_loader()->run()->read_out();
    // handle fixing write permissions
    if ($request->post["param"] == "unlock_config") {
        $result = chmod($config_file, 0664);
        return Effect::create()->api(true, "updated 2", ["file" => $config_file, "mode" => 0664, "result" => $result]);
    }
    // handle toggle on/off to values
    if ($request->post["param"] == "cor_policy") {
        $value = (in_array($request->post["value"], ["false", "off"])) ? "" : "same-origin";
        $request->post["value"] = $value;
    }

    debug("update config [%s]", $config_file);

    $v = $request->post["value"]??"";
    $p = $request->post["param"]??"";

    // ugly hack...
    if ($p == "clear_cache") {
        CacheStorage::get_instance()->delete();
        return Effect::create()->api(true, "cache cleared");
    }

    if (empty($p) || (empty($v) && !icontains($p, ["policy", "dynamic", "ok_apis", "ok_actions", "ok_scripts", "ok_params"]))) {
        return Effect::create()->api(false, "invalid config value for [$p] = [$v]");
    }

    
    if ($p == "ok_apis") {
        $v = strtolower(str_replace("/wp-json", "", $v));


        $parts = explode(",", $v);
        $v = "";
        foreach($parts as $endpoint) {

            $endpoint = trim($endpoint);
            if (strlen($endpoint) < 5) { continue; } // minimum length
            if (substr_count($endpoint, '/') < 3) { continue; } // missing some basic elements
            // trim off anything with spaces....
            if (substr_count($endpoint, ' ') > 0) {
                $endpoint = substr($endpoint, strrpos($endpoint, ' ') + 1);
            }

            if (!starts_with($endpoint, '/')) {
                $v .= '/';
            }
            $v .= rtrim($endpoint, '/') . ', ';
        }

    }
    // update the config file
    $effect = \BitFireSvr\update_ini_value($p, $v);

    // handle auto_start install
    if ($request->post["param"] == "auto_start") {
        $effect->chain(\BitFireSvr\install());
    } 
    
    $effect->api($effect->read_status() == STATUS_OK, "updated");
    // make sure we always clear the php file cache
    $cached_config = get_hidden_file("config.ini.php"); 
    if (file_exists($cached_config)) {
        unlink($cached_config);
        clearstatcache(true, $cached_config);
        if (function_exists("opcache_invalidate")) {
            @opcache_invalidate($cached_config, true);
        }
    }
    $effect->hide_output(false);
    return $effect;
}

/**
 * path is crc32 of path, trim is crc32 of trimmed content
 * @param Request $request 
 * @return Effect - the API response
 */
function allow(\BitFire\Request $request) : Effect {
    require_once WAF_SRC . "server.php";
    // preamble
    $file_name = get_hidden_file("hashes.json");
    $effect = Effect::create();
    $data = un_json($request->post_raw);
    if ($data === null || !isset($data["path"])) {
        return $effect->api(false, "invalid json sent to BitFire API");
    }
    //debug("data\n%s", json_encode($data, JSON_PRETTY_PRINT));
    $type_fn = "\BitFirePlugin\\file_type";
    $ver_fn = '\BitFirePlugin\\version_from_path';
    $root = \BitFireSvr\cms_root();

    $hash = hash_file3($data["path"], $type_fn, $ver_fn, $root);

    if (!$hash) {
        return $effect->api(false, "unable to hash {$data['path']}");
    }
    if (!file_exists($file_name)) { touch($file_name); }

    // load data and filter out this hash
    $file = FileData::create($file_name)
        ->read()
        ->un_json();
    $num1 = count($file->lines);

    $file->filter(function($x) use ($hash) { 
            $match = ($x["path"] == $hash->crc_path) && ($x["trim"] == $hash->crc_trim);
            return !$match;
        });
    $num2 = count($file->lines);

    // add the hash to the list
    $file->lines[] = [ "path" => $hash->crc_path, "trim" => $hash->crc_trim, "file" => $data["path"]??'?' ]; 
    $num3 = count($file->lines);

    debug("allow: %d -> %d -> %d", $num1, $num2, $num3);


    $effect = malware_clear_entry($data["path"]);

    // all good, save the file
    $effect->file(new FileMod($file_name, en_json($file->lines)));



    // report any errors
    if (count($file->get_errors()) > 0) {
        return $effect->api(false, "error saving file allow list", $file->get_errors());
    }
    return $effect->api(true, "file added to allow list", ["id" => $trim, "unique" => $data["unique"]]);
}


function clear_cache(\BitFire\Request $request) : Effect {
    //CacheStorage::get_instance()->clear_cache();
    CacheStorage::get_instance()->delete();
    return \ThreadFin\cache_prevent()->api(true, "cache cleared");
}


/**
 * flag a block for review
 * @param Request $request 
 * @return Effect 
 * @throws RuntimeException 
 */
function review(\BitFire\Request $request) : Effect {


    $post = json_decode($request->post_raw, true);
    if (empty($post)) {
        return Effect::create()->api(false, "Input was not sent correctly");
    }

    $post['time'] = date(DATE_ATOM);
    $r2 = new \BitFire\Request();
    $r2->post = ['start_time' => date('Y-m-d 00:00:01'), 'offset' => 0, 'page_size' => 8, 'batch_sz' => 8, 'page_direction' => PAGE_DIRECTION_BACKWARD, 'include' => ['u:' . $post['uuid']??'77'], 'exclude' => []];

    $api_response = load_bot_data($r2);

    $data = $api_response->read_api();

    if (count($data['data']['data']) > 0) {
        $data = $data['data']['data']; // dereference
        if(isset($data[0])) {
            $ip = $data[0]->ip;
            $r2->post['include'] = [$ip];
            $api_response = load_bot_data($r2);
        }
        $data = $api_response->read_api();
        $data['ver'] = BITFIRE_VER;
        $info = http2("POST", "https://bitfire.co/review.php", json_encode($data));

        $append_review = new FileMod(get_hidden_file("review.json"), json_encode($post) . ",\n", 0, 0, true);
        return Effect::create()->file($append_review)->api(true, "review in progress", ["data" => $info]);
    }
    return Effect::create()->api(false, "reference id not found");
}


/**
 * create effect with error action if user is not admin
 * allow non admin to access sys_info
 * @since 1.9.0
 */
function verify_admin_effect(Request $request) : Effect {
    trace("vae");
    debug_print_backtrace();
    // don't run api calls until inside of the wordpress api
    return (in_array($request->get["BITFIRE_API"]??"", ["sys_info"]) || is_admin()) 
        ? Effect::$NULL
        : Effect::create()->exit(true, STATUS_EACCES, "requires admin access");
}

function api_call(Request $request) : Effect {
    if (isset($request->get[BITFIRE_COMMAND])) {
        $fn = "\\BitFire\\".htmlspecialchars($request->get[BITFIRE_COMMAND]);
    } else if (isset($request->post[BITFIRE_COMMAND])) {
        $fn = "\\BitFire\\".htmlspecialchars($request->post[BITFIRE_COMMAND]);
    } else {
        return Effect::create()->out("no command")->exit(true);
    }

    trace("api");

    // review cases have no auth, so we execute them here
    if (in_array($fn, ['\BitFire\review', '\BitFire\sys_info'])) {
        return $fn($request)->exit(true);
    }

    if (!is_admin()) {
        return Effect::create()->exit(true, STATUS_EACCES, "requires admin access");
    }


    if (!in_array($fn, BITFIRE_API_FN)) {
        return Effect::create()->exit(true, STATUS_ENOENT, "no such method [$fn]");
    }

    if (file_exists(WAF_SRC."proapi.php")) { require_once \BitFire\WAF_SRC . "proapi.php"; }
   

    $post = (strlen($request->post_raw) > 1 && count($request->post) < 1) ? un_json($request->post_raw) : $request->post;
    if ($post === null) { debug("error json decoding api request"); }

    $code = (isset($post[BITFIRE_INTERNAL_PARAM])) 
        ? $post[BITFIRE_INTERNAL_PARAM]
        : $request->get[BITFIRE_INTERNAL_PARAM]??"";;

    if (trim($request->get["BITFIRE_API"]??"") != "send_mfa" && CFG::str("password") != "configure") {
        if (!validate_code($code, CFG::str("secret"), new Hash_Config())) {
            if (!is_admin()) {
                return Effect::create()->api(false, "invalid code", ["error" => "invalid / expired code"])->exit(true);
            }
        }
    }

    $request->post = $post;
    $api_effect = $fn($request);

    assert($api_effect instanceof Effect, "api method did not return valid Effect");
    return $api_effect->exit(true);
}


/**
 * helper binary search. only used in malware scanner
 * TODO: find a better home for this
 * @param array $haystack 
 * @param int $needle 
 * @param int $high 
 * @return bool true if the element is in the list
 */
function in_list(array $haystack, int $needle, int $high) : bool {
    $low = 0;
    $max = 24;
    // handle empty list
    if ($high == 0) { return false; }
      
    while ($low <= $high && $max-- > 0) {
          
        // compute middle index
        $mid = floor(($low + $high) / 2);
   
        // element found at mid
        if($haystack[$mid]??0 == $needle) {
            debug("FOUND @ %d", $mid);
            return true;
        }
  
        // search down
        if ($needle < $haystack[$mid]) {
            //debug("%d < %s (%d, %d) = %d", $needle, $haystack[$mid], $low, $high, $mid);
            $high = $mid -1;
        }
        // search up
        else {
            //debug("%d > %s (%d, %d) = %d", $needle, $haystack[$mid], $low, $high, $mid);
            $low = $mid + 1;
        }
    }
      
    debug("MISSING @ %d", $needle);
    // element x doesn't exist
    return false;
}

function upload_file(string $url, array $post_data, string $path_to_file, string $file_param, ?string $file_name = null) : ?string {
    $data = ""; 
    $boundary = "---------------------".substr(md5(mt_rand(0,32000)), 0, 10); 

    // append post data 
    foreach($post_data as $key => $val) 
    { 
        $data .= "--$boundary\n"; 
        $data .= "Content-Disposition: form-data; name=\"".$key."\"\n\n".$val."\n"; 
    } 

    $data .= "--$boundary\n"; 

    if ($file_name == null) { $file_name = basename($path_to_file); }
    $content = FileData::create($path_to_file)->raw();

    $data .= "Content-Disposition: form-data; name=\"{$file_param}\"; filename=\"{$file_name}\"\n"; 
    $data .= "Content-Type: stream/octet\n"; 
    $data .= "Content-Transfer-Encoding: binary\n\n"; 
    $data .= $content;
    $data .= "\n--$boundary--\n"; 

    $params = array('http' => array( 
           'method' => 'POST', 
           'header' => 'Content-Type: multipart/form-data; boundary='.$boundary, 
           'content' => $data 
        )); 

    $ctx = stream_context_create($params); 
    $fp = fopen($url, 'rb', false, $ctx); 

    if (!$fp) { 
        return debugN("unable to upload file to $url");
    } 

    $response = @stream_get_contents($fp); 
    if ($response === false) { 
        return debugN("unable to read file upload response from $url");
    } 

    return $response;
} 


/**
 * backup the wordpress database 
 * @param Request $request 
 * @return Effect 
 */
function backup_database(Request $request) : Effect {
    $effect = Effect::create();
    require_once WAF_SRC . "db.php";
    require_once WAF_SRC . "wordpress.php";

    // set maximum backup size to allow (uncompressed) (2GB  for pro, 50MB for free)
    $pro = strlen((CFG::str("pro_key")) > 20) ? true : false;
    // check free disk space.  if function is not available assume 1GB
    if (function_exists('diskfreespace')) {
        $space = intval(diskfreespace(CFG::str("cms_content_dir")));
    } else {
        $space = 1024*1024*1024;
    }
    $max_bytes = min($space, (($pro) ? 1024*1024*2024 : 1024*1024*100));
    $sha1 = sha1(CFG::str("secret"));

    // find number of posts and comments included in backup 
    $credentials = \BitFireWP\get_credentials();
    if (empty($credentials)) {
        return $effect->api(false, $message, ["backup_size" => 0, "file" => CFG::str("cms_content_dir") . "no-backup.sql.gz", "store" => "", "status" => "failed - unable to find database credentials"]);
    }
    $db = DB::cred_connect($credentials);
    $prefix = $credentials->prefix;
    $db->enable_log(true);
    $num_posts = $db->fetch("SELECT count(*) as num FROM `{$prefix}posts` p")->col("num")();
    $num_comments = $db->fetch("SELECT count(*) as num FROM `{$prefix}comments` p")->col("num")();

    // backup database to wp-content/db_bitfire.sql.gz
    $backup_file = CFG::str("cms_content_dir")."/db_bitfire.sql.gz";
    $fp = gzopen($backup_file, "wb6");
    $write_fn = ƒixl('gzwrite', $fp);
    $info = dump_database($credentials, $write_fn, $max_bytes);
    gzclose($fp);
    
    // check if backup was successful 
    $backup_size = filesize($backup_file);
    $success = ($backup_size < $max_bytes);
    $message = ($success) ? "database backup complete" : "database backup incomplete";

    // send backup to bitfire server
    $response = upload_file("https://bitfire.co/backup.php?backup_full=1",
    ["secret" => $sha1,
     "posts" => $num_posts,
     "domain" => $_SERVER['HTTP_HOST'],
     "comments" => $num_comments], $backup_file, "full");

    return $effect->api($success, $message, ["backup_size" => $backup_size, "file" => $backup_file, "store" => $response, "status" => $info]);
}



function clean_post(Request $request) : Effect {
    require_once WAF_SRC . "db.php";
    require_once WAF_SRC . "wordpress.php";

    $effect = Effect::create();
    $table = ($request->post["type"]??"" === "post") ? "posts" : "comments";
    $key = ($request->post["type"]??"" === "post") ? "id" : "comment_ID";
    $db = \BitFireWP\get_db_connection();
    $db->enable_log(true);
    //$db->enable_simulation(true);
    $prefix = $db->prefix;
    debug("fix %s (%s)", $prefix, $request->post["fix"]);

    if ($request->post["fix"] == "delete") {
        debug("delete %d", $request->post["id"]);
        $db->delete("`{$prefix}`{$table}", [$key => $request->post["id"]]);
    }
    else if ($request->post["fix"] == "clean") {
        $posts = $db->fetch("SELECT $key, post_content FROM `{$prefix}{$table}` WHERE `$key` = {key}", ["key" => $request->post["id"]]);
        // debug(" # sql [%s]", print_r($db, true));
        if (!$posts->empty()) {
            debug("clean %d len: %d", $posts->count(), strlen($posts->data()[0]["post_content"]));
            debug (" href: /<a[^>]*?{$request->post['link']}.*?>(.*)<\/a>/ims");
            $updated = preg_replace("/<a[^>]*?{$request->post['link']}.*?>(.*)<\/a>/ims", "", $posts->data()[0]['post_content']);
            $ret = $db->update("`{$prefix}{$table}`", ["post_content" => $updated], [$key => $request->post["id"]]);
            debug("updated len: %d (%d)", strlen($updated), $ret);
        }
    }
    else if ($request->post["fix"] == "allow") {
        $domain_file = WAF_ROOT . "/data/good_domains.json";
        $good_domains = FileData::create($domain_file)->read()->un_json()->lines;
        if ($good_domains === null) { debug("error json decoding good_domains.json"); $good_domains = []; }
        $good_domains[$request->post["link"]] = true;
        $effect->file(new FileMod($domain_file, json_encode($good_domains, JSON_PRETTY_PRINT)));
    }

    return $effect->api(false, "clean post", ["data" => $db->logs, "errors" => $db->errors]);
}



function text_to_code_mapper(string $code) : string {
    $mapping = ["challenge" => 24008, "" ];
    return $mapping[$code]??$code;
}

class LogInfo {
    public int $size = 0;
    public $fh;

    public int $pos = 0;
    public int $dir = 1;
    public int $total = 0;
    public int $start_time = 0;
    public string $start_date = '';

    public string $file;

    public function entries() : int {
        return $this->size / LOG_SZ;
    }
}

function open_log(int $start_time, string $direction) : ?LogInfo {
    
    static $opened = [];
    $log = new LogInfo();
    $suffix = date('j', $start_time);
    $log->file = get_hidden_file("weblog.{$suffix}.bin");
    // file doesn't exist, return NULL
    if (!file_exists($log->file)) {
        return NULL;
    }

    // don't open the same file twice...
    if (isset($opened[$log->file])) {
        return NULL;
    }

    $log->fh = fopen($log->file, "rb");
    $log->size = filesize($log->file);
    $log->total = intdiv($log->size, LOG_SZ);
    $log->start_time = $start_time;
    $log->start_date = date('Y-m-d H:i:s', $start_time);

    // if we are looking backward, we need to set the position to the end of the file
    if (strtolower($direction) == PAGE_DIRECTION_BACKWARD) {
        $log->dir = -1;
        $log->pos = $log->total - 1;
    }

    $opened[$log->file] = true;

    return $log;
}

function load_bot_data(Request $request) : Effect {

    $z1 = microtime(true);
    require_once WAF_SRC . "data_util.php";


    $long_names = json_decode(file_get_contents(WAF_ROOT."data/country_name.json"), true);
    $batch_sz = $request->post['batch_sz']??64;
    $includes = $request->post['include']??[];
    $excludes = $request->post['exclude']??[];
    $includes = array_values(array_unique(array_filter(array_map('trim', $includes))));
    $excludes = array_values(array_unique(array_filter(array_map('trim', $excludes))));
    $exclude_codes = [];
    $include_codes = [];
    $include_class = 0;
    $exclude_class = 0;
    $dir = 1;
    $default_start = strtotime('today 00:01');

    $start_time = strtotime($request->post['start_time'] ?? '') ?: $default_start;

    // add 60 seconds to the time for reverse mode to account for possible clock skew. make sure we always return the latest data
    if (($request->post['page_direction'] ?? null) === PAGE_DIRECTION_BACKWARD) {
        $start_time += 60;
        $dir = -1;
        $default_start = strtotime('now');
    }




    $end_time = strtotime($request->post['end_time']??'2038-01-18T01:43') ?: strtotime('2038-01-18T01:43');


    $tz  = intval($request->post['offset']??'0') * 60;
    // limit start time to 30 days in the past
    $start_time = max($start_time, time() - (30 * \ThreadFin\DAY));
    


    // calculate the first file for the log
    $effect = Effect::create()->exit(true);
    
    $log_file = NULL;
    $file_time = $start_time;
    $days = 0;
    while ($log_file == NULL && $file_time < ($start_time + \ThreadFin\DAY * 31) && $file_time > ($start_time - \ThreadFin\DAY * 31)) {
        $log_file = open_log($file_time, $request->post['page_direction']??PAGE_DIRECTION_BACKWARD);
        $file_time += (\ThreadFin\DAY * $dir);
        $days++;
    }
    //if ($log_file == NULL && $days < 2) {
    if ($log_file == NULL) {
        $message = "Unable to find a weblog file";
        if (!is_writeable(dirname(get_hidden_file("weblog.1.bin")))) {
            $message .= ", check file permissions in " . dirname(get_hidden_file("weblog.1.bin"));
        }

        return $effect->api(false, $message);
    }

    $page_skip = ($request->post['page']??0) * $batch_sz;



    $result = [];
    $status_exclude = [];
    $status_include = [];
    $fingerprint_include = [];
    $fingerprint_exclude = [];
    $exclude_class = 0;
    $include_class = 0;
    $country_excludes = [];
    $country_includes = [];
    $uuid_include = [];
    $blocked = -1;


    $status_map = ["ok" => 0, "javascript" => 2];
    $class_map = ["POST" => REQ_POST, "XMLRPC" => REQ_XMLRPC, "JSON" => REQ_WP_JSON, "AJAX" => REQ_AJAX, "DOT" => REQ_DOT, "SCAN" => REQ_NO_PLUGIN, "NOPLUGIN" => REQ_NO_PLUGIN, "LOGINS" => REQ_LOGIN, "DIRECT" => REQ_DIRECT_PHP, "README" => REQ_README, "UNCOMMON" => REQ_UNCOMMON, "UPLOAD" => REQ_UPLOAD | REQ_UPLOAD_PHP, "ADMIN" => REQ_ADMIN, "RESTRICTED" => REQ_RESTRICTED, "USERS" => REQ_USER_LIST, "EVIL" => MASK_EVIL];

    $default_keep = count($request->post['include']??[]) < 1;
    $code_keys    = array_keys(CODE_BOOK);
    $must_hydrate = false;
    // add status exclusions
    for ($i = 0, $m = count($excludes); $i < $m; $i++) {
        $check = strtolower(trim($excludes[$i]));
        $check_u = strtoupper($check);
        if ($check == "blocked" || $check == "flagged") { $blocked = 2; unset($excludes[$i]); }
        else if (intval($check) > 10000 && intval($check < 55000)) {
            $exclude_codes[] = intval($check);
            unset($excludes[$i]);
        }
        else if (isset($status_map[$check])) {
            $status_exclude[] = $status_map[$check]??0;
            unset($excludes[$i]);
        } else if (isset($class_map[$check_u])) {
            $exclude_class |= $class_map[$check_u]??0;
            unset($excludes[$i]);
        }
        else if (isset($long_names[$check_u])) {
            $country_map = array_flip(COUNTRY);
            $country_excludes[intval($country_map[$check_u]??0)] = 1;
            $country_excludes[$check_u] = 1;//intval($country_map[$check_u]??0)] = 1;
            unset($excludes[$i]);
        }
        else if (substr($check, 0, 2) == "0x") {
            $fingerprint_exclude[] = hexdec(substr($check, 2));
            unset($excludes[$i]);
        }
        $parts = explode( " ", $check);
        foreach ($parts as $part) {
            $must_hydrate |= (contains($part, $code_keys));
        }
    }


    // add status inclusions
    $t1 = microtime(true);
    for ($i = 0, $m = count($includes); $i < $m; $i++) {
        $check = strtolower(trim($includes[$i]));
        $check_u = strtoupper($check);
        
        //if (in_array($check, array_keys($status_map))) { 
        if ($check == "blocked" || $check == "flagged") { $blocked = 2; unset($includes[$i]); }
        else if (intval($check) > 10000 && intval($check < 55000)) {
            $include_codes[] = intval($check);
            unset($excludes[$i]);
        }
        if (isset($status_map[$check])) {
            $status_include[] = $status_map[$check]??0;
            unset($includes[$i]);
        } else if (isset($class_map[$check_u])) {
            $include_class |= $class_map[$check_u]??0;
            if ($check_u == "EVIL") { $blocked = 2; }
            unset($includes[$i]);
        }
        else if (isset($long_names[$check_u])) {

            $country_map = array_flip(COUNTRY);
            $country_includes[intval($country_map[$check_u]??0)] = 1;
            $country_includes[$check_u] = 1;//intval($country_map[$check_u]??0)] = 1;
            unset($includes[$i]);

            //$country_map = array_flip(COUNTRY);
            //$country_includes[] = $check_u;//intval($country_map[$check_u]??0)] = 1;
            //unset($excludes[$i]);
        }
        else if (substr($check, 0, 2) == "0x") {
            $fingerprint_include[] = hexdec(substr($check, 2));
            unset($includes[$i]);
        }
        else if (substr($check, 0, 2) == "u:") {
            $uuid_include[] = hexdec(substr($check, 2));
            unset($includes[$i]);
        }
        $parts = explode( " ", $check);
        foreach ($parts as $part) {
            $must_hydrate |= (contains($part, $code_keys));
        }
    }


    $ctr = 0;
    $l = 0;
    $m = 0;
    $page_start = $page_skip;
    $total = $log_file->total;

    // the unpack format
    $max1 = $max2 = $max3 = 0;
    $format = P16 . 'flags/' . P8 . 'valid/' . P64 . 'fingerprint/' . 'A24signature/' . PA16 . 'ip/' . P16 . 'ctr_404/' . P16 . 'rr/' .
            P16 . 'http_code/' . P16 . 'block_code/' . P8 . 'method/' . P32 . 'post_len/' . P32 . 'out_len/' . P32 . 'time/' .
            P32 . 'class/' .  P16 . 'no1/' . P8 . 'country_id/' . P32 . 'uuid/' . 'A12no6/' .
            PS . 'str1';


            //die("default keep ($c1 / $c2) ($default_keep)\n");
    $file_ctr = 0;
    do {
        $x1 = microtime(true);
        $off = ($log_file->pos * LOG_SZ);

        // guard
        if ($file_ctr >= 32 || $log_file == null) {
            break;
        }
        $log_file->pos += $log_file->dir;

        if ($log_file->pos < 0 || $log_file->pos > $log_file->total) {
            $log_file->start_time += \ThreadFin\DAY * $log_file->dir;
            $tmp = open_log($log_file->start_time, $request->post['page_direction']??PAGE_DIRECTION_BACKWARD);
            $file_ctr++;
            // no more data to inspect...
            if ($tmp === null) {
                continue;
            }
            $days++;
            $log_file = $tmp;
            $total += $log_file->total;
            continue;
        };
        if (fseek($log_file->fh, $off) < 0) {
            return $effect->api(false, "unable to seek weblog: {$log_file->pos}");
        }
        $raw = fread($log_file->fh, LOG_SZ);
        $l = strlen($raw);
        if ($l < LOG_SZ) { 
            $m = 1; 
            break;
        }

        
        // replacements for webcasting
        // $raw = str_replace("company", "web--site", $raw);
        $data = unpack($format, $raw);
        $x2 = microtime(true);
        if (($x2 - $x1) > $max1) { $max1 = $x2 - $x1; }

        if ($data === false) { $m = 2; break; }
        //if (empty($data['code'])) { $m = 6; break; }
        if ($request->post['page_direction'] == PAGE_DIRECTION_FORWARD) {
            if ($data['time']  <  $start_time) { $m = 8; continue; }
        } else {
            if ($data['time']  >  $start_time) { $m = 9; continue; }
        }
        
        if ($data['time']  >  $end_time) { $m = 10; continue; }
        if (count($result) >= $batch_sz) { $m = 4; break; }

        $ip = inet_ntop($data['ip']);
        if (!$ip) { 
            $ip = ord($data['ip'][0]??0).".".  ord($data['ip'][1]??0).".".  ord($data['ip'][2]??0).".".  ord($data['ip'][3]??0);
        }
        $data['out_len'] = ($data['out_len'] == 4294967295) ? 0 : $data['out_len'];


        // first let's exclude
        // process excludes
        if (contains($ip, $excludes)) { continue; }
        if (contains($data['str1'], $excludes)) { continue; }
        if (in_array($data['valid'], $status_exclude) && $data['block_code'] === 0) { continue; }
        if (in_array($data['fingerprint'], $fingerprint_exclude)) { continue; }
        if (in_array($data['block_code'], $exclude_codes)) { continue; }
        if ($exclude_class > 0 && (intval($data['class']) & $exclude_class) > 0) { continue; }
        // skip excluded request classifications
        if ($exclude_class > 0 && (intval($data['class']) & $exclude_class)) { continue; }
        $cn=intval($data['country_id']);
        if (isset($country_excludes[$cn])) {
            continue;
        }
        if (!$must_hydrate) {
            if (contains($data['str1'], $excludes)) {
                continue;
            }
        }


        $log = ($must_hydrate) ? hydrate_log($data, true, $tz) : null;
        
        if (!empty($log)) {
            if (contains($log->ua, $excludes)) { continue; }
            if (contains($log->url, $excludes)) { continue; }
        } 
        $keep = $default_keep;

        $x3 = microtime(true);
        if (($x3 - $x2) > $max2) { $max2 = $x3 - $x2; }

        if (!$keep) {
            if (!empty($uuid_include) && isset($data['uuid']) && in_array(intval($data['uuid']), $uuid_include)) {
                $keep = true;
            } 
            if ($include_class > 0 && (intval($data['class']) & $include_class) > 0) {
                $keep = true;
            }

            if (contains($ip, $includes)) { $keep = true; }
            if (!empty($log)) {
                if (contains($log->ua, $includes)) { $keep = true; }
                if (contains($log->url, $includes)) { $keep = true; }
            } 
            else if (contains($data['str1'], $includes)) {
                $keep = true;
            }
            if (in_array($data['valid'], $status_include)) {
                // only keep if we have no block code
                if ($data['block_code'] === 0) {
                    $keep = true;
                }
            }
            if (in_array(intval($data['fingerprint']), $fingerprint_include)) {
                $keep = true;
            }
            if (in_array($data['block_code'], $include_codes)) {
                $keep = true;
            }

            

            $cn=intval($data['country_id']);
            if (isset($country_includes[$cn])) {
                continue;
            }
            if (!$keep && $blocked > 0) {
                $keep = ($blocked >= 1 && $data['block_code'] > 0) ? true : false;
            }
            
        }
        if (!$keep) { continue; }

        // lastly skip anything that is past our page number
        if ($page_skip > 0) { $page_skip--; continue; }

        if (empty($log)) {
            $log = hydrate_log($data, true, $tz);
        }

        // exclude country 
        if (isset($country_excludes[$log->loc->iso])) { continue; }
        // include country
        if (count($country_includes) > 0 && !isset($country_includes[$log->loc->iso])) { continue; }


        $log->pos = $log_file->pos+1;

        $x4 = microtime(true);
        if (($x4 - $x1) > $max3) { $max3 = $x4 - $x1; }

        $r0 = ($x4 - $z1);
        // don't let the processing time exceed 500ms
        if ($r0 > 0.500) {
            $m = 30;
            break;
        }
        $filename = basename($log->favicon);
        if ($log->favicon == $filename) {
            $log->favicon = CFG::str("cms_content_url") . "/plugins/bitfire/public/browsers/$filename";
        }
        $result[] = $log;
    } while ($ctr++ < 100000);

    $t2 = microtime(true);

    $hidden_dir = get_hidden_file('');
    $log_files = glob($hidden_dir . DS . "*.bin");

    $r = ['clazz' => $include_class, "days" => min($days, count($log_files)), "weblog_file" => $log_file->file, "forward" => $request->post['page_direction'], "m1" => $x4??0, "m2" => $z1??0 , "must_hydrate" => $must_hydrate, "ctr" => $ctr, "t2" => $total, "ln" => $total, "cres" => count($result), "len" => $l??0, "stime" => date("Y-m-d H:i:s", $start_time), "etime" => date("Y-m-d H:i:s", $end_time), "total" => $total, "skip" => $page_skip??0, "start" => $page_start??0, "end" => $page_start + count($result), "pos" => $log_file->pos??0, "m" => $m??0, "duration" => $t2 - $t1, "data" => $result];
    /*
    $t2 = $total;
    if (count($result) < $batch_sz && $ctr >= $total -2) {
        $total = count($result);
    }
    */

    return $effect->api(true, "data loaded", $r);
}

function remove_ip_block(Request $request) : Effect {
    $effect = Effect::create();

    $file = $request->post['block']??'';
    if (contains($file, '..') || ends_with($file, '.php')) {
        return $effect->api(false, 'invalid file name');
    }
    $ip = basename($file);
    $path = realpath(BLOCK_DIR . DS . $ip);
    if (file_exists($path)) {
        $effect->unlink($path);
        $effect->api(true, "removed IP block for $ip");
    } else {
        $effect->api(false, "IP $ip, $path is not blocked");
    }

    return $effect;
}



function scan_malware(Request $request) : Effect {
    require_once WAF_SRC . "db.php";
    require_once WAF_SRC . "wordpress.php";

    $href_list = [];
    $script_list = [];
    $bad_domains = [];
    $effect = Effect::create();
    $table = ($request->post["type"]??"" === "post") ? "posts" : "comments";
    $key = ($request->post["type"]??"" === "post") ? "id" : "comment_ID";
    $join_col = ($request->post["type"]??"" === "post") ? "post_author" : "user_id";
    $malware_file = WAF_ROOT . "/data/malware.bin";
    if (!file_exists($malware_file)) {

        if (function_exists('curl_init')) {
            set_time_limit(0);
            $fp = fopen ($malware_file, 'w+');
            $ch = curl_init("https://bitfire.co/malware/malware.bin");
            curl_setopt($ch, CURLOPT_TIMEOUT, 600);
            curl_setopt($ch, CURLOPT_FILE, $fp); 
            curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
            curl_exec($ch); 
            curl_close($ch);
            fclose($fp);
        } else {
            $malware_bin = file_get_contents("https://bitfire.co/malware/malware.bin");
            file_put_contents($malware_file, $malware_bin, LOCK_EX);
        }
    }
    $half_size = filesize($malware_file) / 2;
    $offset = ($request->post["side"]??"" === "right") ? $half_size : 0;
    $good_domains = FileData::create(WAF_ROOT . "/data/good_domains.json")->read()->un_json()->lines;
    $self_url = parse_url(CFG::str("cms_content_url"), PHP_URL_HOST);

    debug(" # malware 1/2 size %d", $half_size);

    // connect to DB
    $db = \BitFireWP\get_db_connection();
    $prefix = $db->prefix;
    if (!$db) {
        return $effect->api(false, "error", ["message" => "could not connect to database"]);
    }
    $max_post = $db->fetch("SELECT max(id) FROM `{$prefix}{$table}`")->col("id")->value("int");

    // load the content from the database
    $posts = $db->fetch("SELECT p.`$key`, post_content, post_title, u.display_name, post_date FROM `{$prefix}{$table}` p LEFT JOIN `{$prefix}users` u ON p.{$join_col} = u.id ORDER BY `$key` ASC LIMIT {page_size} OFFSET {offset}", ["page_size" => 250, "offset" => $request->post["offset"]]);
    if ($posts->empty()) {
        return $effect->api(true, "complete", ["message" => "All $table scanned", "logs" => $db->logs]);
    }

    // load the left or right side of the malware file
    $malware_raw = file_get_contents($malware_file);//, false, null, $offset, $half_size);
    //$malware_raw = file_get_contents($malware_file, false, null, $offset, $half_size);
    $malware = unpack("N*", $malware_raw);
    $malware_total = count($malware);
    //debug("read $malware_total malware hashes [$offset : $half_size] : " . strlen($malware_raw));


    $max_found_id = 0;
    foreach ($posts->data() as $post) {

        // calculate seconds since the post was created/updated
        $parsed = date_parse($post["post_date"]);
        $new_epoch = mktime(
            $parsed['hour'], 
            $parsed['minute'], 
            $parsed['second'], 
            $parsed['month'], 
            $parsed['day'], 
            $parsed['year']
        );
        $seconds = time() - $new_epoch;

        if ($post["id"] > $max_found_id) { $max_found_id = $post["id"]; }

        // find all scripts in the post
        if (preg_match_all("/<script([^>]*)>([^<]*)/ims", $post["post_content"], $scripts)) {
            foreach ($scripts as $script) {
                $script_list[] = [
                    "id" => $post["id"],
                    "title" => $post["post_title"],
                    "author" => $post["display_name"],
                    "date" => $post["post_date"],
                    "days" => ceil($seconds/DAY),
                    "markup" => $script[1]??"",
                    "content" => substr($script[2]??"", 0, 2048)
                ];
            }
        }

        // find all links in the post
        if (preg_match_all("/<a[^>]+>/ims", $post['post_content'], $links)) {
            foreach ($links as $link) {
                // skip link if it is marked nofollow, or user content
                //if (icontains($link[0], ["nofollow", "ugc"])) {
                //    continue;
                //}
                // skip the link if it's not a full path...
                if (!icontains($link[0], "http")) {
                    continue;
                }
                // it's a real link
                if (preg_match("/href\s*=\s*[\"\']?\s*([^\s\"\']+)/ims", $link[0], $href)) {
                    // exclude links to ourself...
                    // $source = substr($href[1], 0, strlen($self_url) + 16);
                    // if (icontains($source, $self_url)) { continue; }

                    // get just the domain name
                    $check_domain = preg_replace("/https?:\/\/([^\/]+).*/ims", '\1', $href[1]);
                    debug(" # href [%s] = [%s]", $href[1], $check_domain);

                    // skip domains we have already allowed
                    if (isset($good_domains[$check_domain])) { continue; }

                    // TODO: add list of Top 1000 domains and check those first to exclude the link here
                    $hash = crc32($check_domain);

                    // only search the malware list 1x
                    if (!isset($bad_domains[$check_domain])) {
                        if (in_list($malware, $hash, $malware_total)) {
                            $bad_domains[$check_domain] = true;
                        } else {
                            debug(" # good domain [%d] %s", $hash, $check_domain);
                            $good_domains[$check_domain] = true;
                        }
                    }

                            
                    if (isset($bad_domains[$check_domain])) {
                        $href_list[] = [
                            "id" => $post["id"],
                            "name" => $post["display_name"],
                            "title" => $post["post_title"],
                            "date" => $post["post_date"],
                            "days" => ceil($seconds/DAY),
                            "markup" => $link[0],
                            "domain" => $check_domain,
                            "type" => $request->post["type"]??"post",
                            "md5" => md5($check_domain),
                            "hash" => $hash
                        ];
                    }
                }
            }
        }
    }

    $next = ($max_found_id < $max_post) ? $offset + 250 : 0;

    return $effect->api(true, "scan complete", [
        "hrefs" => $href_list,
        "offset" => $offset,
        "size" => 250,
        "next_offset" => $next,
        "side" => $request->post['size']??'Left',
        "table" => $table,
        "scripts" => $script_list,
        "good_domains" => $good_domains,
        "bad_domains" => $bad_domains
    ]);
}

function opcache_extract_section(string $html): string {
    $start_tag = 'Zend OPcache</a></h2>';

    $start_pos = stripos($html, $start_tag);
    if ($start_pos === false) {
        return ''; // Not found
    }
    $start_pos += strlen($start_tag);

    // Start from the matched <h2>
    $section_start = $start_pos;

    // Look for the next <h2> after the one we want
    $next_h2_pos = strpos($html, '<h2>', $section_start + strlen($start_tag));
    if ($next_h2_pos === false) {
        // No other <h2> found, return everything after the current one
        return substr($html, $section_start);
    }

    // Extract only the block between the Zend OPcache <h2> and the next <h2>
    return substr($html, $section_start, $next_h2_pos - $section_start);
}

function opcache_parse_phpinfo(string $html): array {
    $results = [];

    $interesting = ['hits' => 0, 'miss' => 0, 'free' => 0, 'used' => 0, 'scripts' => 0];
    // rare, but it can happen.
    // rare, but it can happen....
    if (!class_exists('DOMDocument')) {
        return $interesting;
    }
    if (empty($html)) {
        return $interesting;
    }

    // Use DOMDocument to parse the HTML
    $dom = new \DOMDocument();
    libxml_use_internal_errors(true); // Suppress HTML5 parsing errors
    $dom->loadHTML($html);
    libxml_clear_errors();

    $rows = $dom->getElementsByTagName('tr');

    if (!empty($rows)) {
        foreach ($rows as $row) {
            $cols = $row->getElementsByTagName('td');
            if (!empty($cols) && $cols->length === 2) {
                $key = strtolower(trim($cols->item(0)->textContent));
                $val = trim($cols->item(1)->textContent);
                $results[$key] = $val;
            }
        }
    }

    $interesting = ['hits' => $results['cache hits'], 'miss' => $results['cache misses'], 'free' => $results['free memory'], 'used' => $results['used memory'], 'scripts' => $results['cached scripts']];

    return $interesting;
}

function function_is_disabled(string $func): bool {
    static $disabled = null;
    
    if ($disabled === null) {
        $disabled = explode(',', ini_get('disable_functions') ?? '');
        $disabled = array_map('trim', $disabled);
    }

    return in_array($func, $disabled, true);
}


function cpu_percent_normalized(float $interval = 0.5): array {
    // --- helper: count physical cores visible to the kernel
    $sysCores = (function (): int {
        $dirs = glob('/sys/devices/system/cpu/cpu[0-9]*', GLOB_ONLYDIR) ?: [];
        return max(1, count($dirs));
    })();

    // --- cgroup effective cores (reuse your earlier detect_effective_cores if you have it)
    $effectiveCores = (function () use ($sysCores): float {
        // v2 cpu.max
        if (file_exists('/sys/fs/cgroup/cpu.max')) {
            if (($s = @file_get_contents('/sys/fs/cgroup/cpu.max')) !== false &&
                preg_match('/^(\S+)\s+(\S+)/', trim($s), $m)) {
                if ($m[1] !== 'max') {
                    $q = (int)$m[1]; $p = max(1, (int)$m[2]);
                    return max(0.01, $q / $p);
                }
            }
            return (float)$sysCores;
        }
        // v1 cpu.cfs_* (try common mounts)
        foreach (['/sys/fs/cgroup/cpu', '/sys/fs/cgroup/cpu,cpuacct', '/sys/fs/cgroup/cpuacct'] as $base) {
            $p = $q = false;
            if (file_exists("$base/cpu.cfs_quota_us")) {
                $q = @file_get_contents("$base/cpu.cfs_quota_us");
            }
            if (file_exists("$base/cpu.cfs_period_us")) {
                $p = @file_get_contents("$base/cpu.cfs_period_us");
            }
            if ($q !== false && $p !== false) {
                $qi = (int)trim($q); $pi = max(1, (int)trim($p));
                if ($qi > 0) return max(0.01, $qi / $pi);
                return (float)$sysCores; // unlimited
            }
        }
        return (float)$sysCores; // unknown → assume system cores
    })();

    // --- read cgroup CPU usage (seconds)
    $readUsage = function (): ?float {
        // v2: cpu.stat (usage_usec or user_usec+system_usec)
        if (file_exists('/sys/fs/cgroup/cpu.stat')) {
            $txt = @file_get_contents('/sys/fs/cgroup/cpu.stat');
            if ($txt !== false) {
                $u = $uu = $su = null;
                foreach (explode("\n", trim($txt)) as $ln) {
                    if (preg_match('/^usage_usec\s+(\d+)/', $ln, $m)) $u  = (int)$m[1];
                    if (preg_match('/^user_usec\s+(\d+)/',  $ln, $m)) $uu = (int)$m[1];
                    if (preg_match('/^system_usec\s+(\d+)/',$ln, $m)) $su = (int)$m[1];
                }
                if ($u !== null)        return $u  / 1e6;
                if ($uu !== null || $su !== null) return ((int)($uu ?? 0) + (int)($su ?? 0)) / 1e6;
            }
        }
        // v1: cpuacct.usage (ns) or cpuacct.usage_percpu
        foreach (['/sys/fs/cgroup/cpuacct','/sys/fs/cgroup/cpu','/sys/fs/cgroup/cpu,cpuacct'] as $base) {
            $ns = $per = false;
            if (file_exists("$base/cpuacct.usage")) {
                $ns = @file_get_contents("$base/cpuacct.usage");
            }
            if ($ns !== false && ctype_digit(trim($ns))) {
                $val = (int)trim($ns);
                if ($val > 0) return $val / 1e9;
            }
            if (file_exists("$base/cpuacct.usage_percpu")) {
                $per = @file_get_contents("$base/cpuacct.usage_percpu");
            }
            if ($per !== false) {
                $sum = 0;
                foreach (preg_split('/\s+/', trim($per)) as $n) {
                    if ($n !== '' && ctype_digit($n)) $sum += (int)$n;
                }
                if ($sum > 0) return $sum / 1e9;
            }
        }
        return null;
    };

    // --- try cgroup measurement over an interval
    $u1 = $readUsage();
    if ($u1 !== null) {
        $t1 = hrtime(true);
        usleep((int)($interval * 1e6));
        $u2 = $readUsage();
        $t2 = hrtime(true);
        if ($u2 !== null) {
            $du = max(0.0, $u2 - $u1);              // CPU seconds used
            $dt = max(1e-9, ($t2 - $t1) / 1e9);     // wall seconds
            $pct = ($du / ($dt * max(0.01, $effectiveCores))) * 100.0;
            return ['percent' => round(min(100.0, max(0.0, $pct)), 3), 'cores' => $effectiveCores, 'method' => 'cgroup'];
        }
    }

    // --- fallback: loadavg/cores saturation
    $avg = sys_getloadavg();            // [1m, 5m, 15m]
    $load = (float)($avg[0] ?? 0.0);    // 1-minute load
    $pct  = round(min(100.0, max(0.0, ($load / $effectiveCores) * 100.0)), 3);

    return ['percent' => $pct, 'cores' => $effectiveCores, 'method' => 'loadavg'];
}



function sys_info(Request $request): Effect {
    $effect = Effect::create();

    // Optional preload
    $ini_file = \BitFire\WAF_ROOT . 'ini_info.php';
    if (is_readable($ini_file)) {
        include $ini_file;
    }

    // perform server authentication if we are not running from WP-CLI
    if (!defined('WP_CLI')) {
        // DNS forward and reverse verification, this can only run from bitfire.co
        $domain = ip_to_domain($request->ip);
        if ($domain !== 'bitfire.co') {
            return $effect->api(false, 'invalid DNS');
        }

        // PKI + shared secret authentication
        require_once WAF_SRC . 'server.php';
        $maybe_auth = authenticate_tech($request->get['auth'] ?? '');
        $s = CFG::str('server_id', 'NO_EMPTY_STRINGS');
        if ($maybe_auth->compare($s) === false) {
            return $effect->api(false, "invalid authentication[$s]: " . (string)$maybe_auth());
        }
    }

    // Extract OPcache info
    ob_start();
    phpinfo();
    $info = ob_get_clean();
    $opcache_section = opcache_extract_section($info);
    $opcache_data = opcache_parse_phpinfo($opcache_section);

    // CPU count fallback

    // Load average
    $load = cpu_percent_normalized(0.5);

    // Memory info
    $mem_total = $mem_free = $cached = $swap = 0;
    if (is_readable('/proc/meminfo')) {
        $raw = file_get_contents('/proc/meminfo');
        preg_match('/MemTotal:\s+(\d+)/i', $raw, $m) && $mem_total = (int)$m[1];
        preg_match('/MemFree:\s+(\d+)/i', $raw, $m) && $mem_free = (int)$m[1];
        preg_match('/Cached:\s+(\d+)/i', $raw, $m) && $cached = (int)$m[1];

        if (preg_match('/SwapTotal:\s+(\d+)/i', $raw, $swap_total)
            && preg_match('/SwapFree:\s+(\d+)/i', $raw, $swap_free)) {
            $swap = (int)$swap_total[1] - (int)$swap_free[1];
        }
    }

    // OPcache INI settings
    $ini_keys = [
        'opcache.enable',
        'opcache.memory_consumption',
        'opcache.max_accelerated_files',
        'opcache.file_cache_only',
        'opcache.file_cache'
    ];
    $opcache_ini = [];
    foreach ($ini_keys as $key) {
        $opcache_ini[$key] = ini_get($key);
    }

    // Cache stats
    $cache_info = [];
    $cache = CacheStorage::get_instance();
    for ($i = 0; $i < 768; $i++) {
        $val = (int)$cache->load_data("STAT_$i", -1);
        if ($val > 0) {
            $cache_info["stat_$i"] = $val;
        }
    }

    require_once WAF_SRC . "db.php";
    require_once WAF_SRC . "wordpress.php";

    // check free disk space.  if function is not available assume 1GB
    $free_space = -1;
    if (function_exists('diskfreespace') && !function_is_disabled('diskfreespace')) {
        $free_space = intval(diskfreespace(CFG::str("cms_content_dir")));
    }

    // find number of admin accounts
    $credentials = \BitFireWP\get_credentials();
    $plugin_act  = 0;
    $cron_events = 0;
    $autoload_sz = 0;
    $mysql_ver   = 'unknown';
    $admin_names = [];
    if (!empty($credentials)) {
        $db = DB::cred_connect($credentials);
        $prefix = $credentials->prefix;
        $admin_sql = $db->fetch("SELECT user_login
FROM {$prefix}users u
JOIN {$prefix}usermeta m ON u.ID = m.user_id
WHERE m.meta_key = 'wp_capabilities'
  AND m.meta_value LIKE '%administrator%';");
        $tmp = $admin_sql->data();
        $admin_names = (!empty($tmp)) ? array_values($tmp) : [];

        $active_sql = $db->fetch("SELECT option_value AS act FROM {$prefix}options WHERE option_name = 'active_plugins'");
        $plugin_ser = $active_sql->col('act')->value('string');
        $plugin_act = (!empty($plugin_ser)) ? count(unserialize($plugin_ser)) : 0;

        $cron_sql = $db->fetch("SELECT option_value AS cron FROM {$prefix}options WHERE option_name = 'cron'");
        $cron_ser = $cron_sql->col('cron')->value('string');
        $cron_events = (!empty($plugin_ser)) ? count(unserialize($cron_ser)) : 0;

        $ver_sql = $db->fetch("SELECT VERSION() as ver");
        $mysql_ver = $ver_sql->col('ver')->value('str', 'unknown');

        $autoload_sql = $db->fetch("SELECT SUM(LENGTH(option_value)) AS total_autoload FROM {$prefix}options WHERE autoload = 'yes'");
        $autoload_sz = $autoload_sql->col('total_autoload')->value('int', 0);
    }

    $plugins = [];
    if (function_exists('get_plugins')) {
        if (file_exists(WAF_ROOT . '/plugin_mapping.php')) {
            include_once WAF_ROOT . '/plugin_mapping.php';
        }
        $all_plugins = get_plugins();
        foreach ($all_plugins as $file => $data) {
            $slug = path_to_slug($file);
            $plugins[$slug] = $data['Version'] ?? '?';
            if (defined("BITFIRE_PLUGIN_MAP") && isset(BITFIRE_PLUGIN_MAP[$slug])) {
                $plugins[$slug] .= ":" . BITFIRE_PLUGIN_MAP[$slug];
            } else {
                $plugins[$slug] .= ":unknown";
            }
        }
    }
    
    // Compile final payload
    $data = [
        'timestamp'   => time(),
        'date'        => date('Y-m-d H:i:s'),
        'php'         => PHP_VERSION_ID,
        'wp_ver'      => CFG::str('wp_version'),
        'mysql_ver'   => $mysql_ver,
        'release'     => BITFIRE_SYM_VER,
        'plugin_act'  => $plugin_act,
        'admin_count' => count($admin_names),
        'admins'      => $admin_names,
        'mysql_ver'   => $mysql_ver,
        'autoload_sz' => $autoload_sz,
        'cron_events' => $cron_events,
        'cpu'         => $load,
        'cache_type'  => CFG::str('cache_type'),
        'timezone'    => date_default_timezone_get(),
        'mem_total'   => $mem_total,
        'mem_free'    => $mem_free,
        'mem_percent' => ($mem_total > 0) ? round((1 - ($mem_free / $mem_total)) * 100, 3) : 0.0,
        'cached'      => $cached,
        'cache_info'  => $cache_info,
        'server'      => $_SERVER['SERVER_SOFTWARE'] ?? 'unknown',
        'swap'        => $swap,
        'file_edit'   => (defined('DISALLOW_FILE_EDIT') && DISALLOW_FILE_EDIT) ? true : false,
        'debug_mode'  => (defined('WP_DEBUG') && WP_DEBUG) ? true : false,
        'op_cache'    => $opcache_ini,
        'op_data'     => $opcache_data,
        'free_space'  => $free_space,
        'admin_name'  => $admin_names,
        'mysql_ver'   => $mysql_ver,
        'plugins'     => $plugins,
        'site'        => $_SERVER['HTTP_HOST'] ?? '',
        'self_cache'  => function_exists('opcache_is_script_cached')
                            ? opcache_is_script_cached(WAF_SRC . 'bitfire.php')
                            : 'no_opcache',
    ];

    return $effect->api(true, 'system info', $data);
}





/**
 * called repeatedly to check the status of the AI scan
 * @param Request $request 
 * @return Effect 
 * @throws RuntimeException 
 */
function ai_scan(Request $request): Effect {
    $effect = Effect::create();
    $lock_path = get_hidden_file("malware.lock");
    $malware_path = get_hidden_file("malware.jsonl");
    $malware_file = FileData::create($malware_path);

    // no malware samples to send
    if (!$malware_file->exists) {
        return $effect->api(false, 'No malware samples found', ['complete' => 1, 'status' => 'AI Scan Not Started', 'note' => "no malware samples found $malware_path"]);
    }

    // have malware samples start a new scan...
    if (!file_exists($lock_path)) {
        touch($lock_path);
        if (!file_exists($lock_path)) {
            return $effect->api(false, 'Unable to create AI lock file', ['complete' => 1, 'status' => 'AI Scan Not Started', 'note' => "unable to create lock file: $lock_path"]);
        }
        if (!file_exists($malware_path)) {
            return $effect->api(false, 'No malware samples found', ['complete' => 1, 'status' => 'AI Scan Not Started', 'note' => "no malware samples found $malware_path"]);
        }

        // send the samples for analysis

        $final = [];
        $malware_lines = file($malware_path);
        foreach ($malware_lines as $line) {
            if (empty($line)) {
                continue;
            }
            $x = json_decode($line, true);
            if (is_array($x)) {
                $final[] = $x;
            }
        }

        $result = http2("POST", "https://bitfire.co/ai.php?scan=ai&src=".$_SERVER['HTTP_HOST'], json_encode($final), ['timeout' => 3000]);
        if ($result->http_code == 200) {
            return $effect->api(true, 'AI Samples sent for processing', ['complete' => 0, 'status' => 'AI Scan Started', 'note' => "Processing " . $malware_file->num_lines . " malware samples"]);
        }
        return $effect->api(false, 'Unable to create AI lock file', ['complete' => 1, 'status' => 'AI Scan Error', 'note' => $result->content]);
    }

    // a scan is already running, poll the server for updates
    $result = http2("POST", "https://bitfire.co/ai.php?scan=check&src=".$_SERVER['HTTP_HOST'], '', ['timeout' => 3000]);
    $success = false;
    if ($result->http_code == 100) {
        $note = "AI Scan In Progress";
        $success = true;
    }
    else if ($result->http_code == 200) {
        $note = "AI Scan Complete";
        $success = true;
    }
    else {
        $note = "AI Scan Error";
        $success = false;
    }

    $data = json_decode($result->content, true);
    $num_lines = is_array($data) ? count($data) : 0;
    $effect->api($success, $note, ['complete' => ($result->http_code == 200) ? 1 : 0, 'status' => $note, 'note' => $num_lines . ' Files Processed','data' => base64_encode($result->content)]);

    return $effect;
}

function repair2(\BitFire\Request $request) : \ThreadFin\Effect {
    require_once WAF_ROOT . "/src/server.php";
    require_once WAF_ROOT . "/includes.php";
    $root = "";

    $effect = \ThreadFin\Effect::create();

    $d = file_get_contents("php://input");
    $x = json_decode($d, true);
    $path = $x['filename'];
    $id = $x['id'];
    $normal_path = str_replace('\\', '/', trim($path)); 


    $type = \BitFirePlugin\file_type($normal_path);
    $name = \BitFirePlugin\file_name($normal_path);
    $ver = \BitFirePlugin\file_ver($normal_path, $type);
    if ($type === "wp_core") {
        $rel_path = str_after_first($normal_path, CFG::str("cms_root"));
    } else {
        $rel_path = (empty($name)) ? $normal_path : str_after_first($normal_path, $name);
    }
    $rel_path = ltrim(str_replace('//', '/', $rel_path), '/'); // remove double slashes and leading slash


    $info = http2("GET", "https://hash.bitfire.co/wordpress_fetch.php?type={$type}&slug={$name}&ver={$ver}&path={$rel_path}", "", [
    "User-Agent" => "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36",
    "Accept" => "*/*",
    "timeout" => 5000,
    "sec-ch-ua-platform" => "Linux",
    "upgrade-insecure-requests" => "1"]);

    // if we don't have a 200, then 0 out the 404 response.
    if (!$info->success) { $info["success"] = false; $info["content"] = ""; }
    $orig = $info["content"];


    if (!empty($orig) && strlen($orig) > 1) {
        $local = file_get_contents($path);
        http2("POST", "https://bitfire.co/zxf.php?action=malware", base64_encode($local));
        $q_dir = get_hidden_file("quarantine/");
        if (!file_exists($q_dir)) {
            mkdir($q_dir, 0775, true);
        }
        $wrote = file_put_contents($normal_path . ".tmp", $info->content, LOCK_EX);
        if ($wrote == $info->len) {
            // archive the old file
            $q_file = $q_dir . basename($normal_path) . ".bak." . mt_rand(10000, 99999);
            if (file_exists($normal_path) && !rename($normal_path, $q_file)) {
                return $effect->api(false, "unable to move modified file to quarantine", ["file" => $normal_path, "out" => $q_file, "id" => $id, "error" => "unable to rename original file '$normal_path'"]);
            }


            // replace file with fresh copy
            $success = rename($normal_path . ".tmp", $normal_path);
            if ($success) {
                $effect = malware_clear_entry($normal_path);
                return $effect->api(true, "repaired file " . basename($normal_path), ["id" => $id]);
            } else {
                return $effect->api(false, "unable to rename new file", ["tmp" => "{$normal_path}.tmp", "file" => $normal_path, "id" => $id, "error" => "unable to rename new file '{$normal_path}.tmp' to '$normal_path'"]);
            }
        }
        return $effect->api(false, "unable to write repaired", ["id" => $id, "error" => "unable to write original file from wordpress.org"]);
    } else {
        return $effect->api(false, "unable to rename new file", ["id" => $id, "error" => "unable to read original file from wordpress.org"]);
    }
    return $effect;
}

// validate $hash was generated with make_code($secret)
function validate_code(string $hash, string $secret, ?Hash_Config $config = null) : bool {
    $parts = explode(".", $hash);
    if ($config === null) { $config = new Hash_Config(); }
    return validate_raw($parts[0]??"", $parts[1]??"", $parts[2]??"", $secret, $config);
}

