bitslip6/bitfire

View on GitHub
firewall/src/storage.php

Summary

Maintainability
A
2 hrs
Test Coverage
<?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
 * 
 */

namespace ThreadFin;
use \BitFire\Config as CFG;

use const BitFire\WAF_ROOT;

/**
 * generic storage interface for temp / permanent storage
 */
interface Storage {
    public function save_data(string $key_name, $data, int $ttl) : bool;
    public function load_data(string $key_name);
    public function load_or_cache(string $key_name, int $ttl, callable $generator);
    public function update_data(string $key_name, callable $fn, callable $init, int $ttl);
    public function delete();
}

/**
 * Abstraction around a single cache entry
 */
class CacheItem {
    public $key;
    public $fn;
    public $init;
    public $ttl;

    public function __construct(string $key_name, callable $fn, callable $init, int $ttl) {
        $this->key = $key_name;
        $this->fn = $fn;
        $this->init = $init;
        $this->ttl = $ttl;
    }
}

/**
 * trivial cache abstraction with support for apcu, shared memory and zend opcache 
 */
class CacheStorage implements Storage {
    protected static $_type = 'nop';
    protected static $_instance = null;
    protected $_shmop = null;
    protected $_shm = null;
    public $expires = -1;

    /**
     * delete all stored cache data including shmop and semaphores
     * @return void 
     */
    public function delete() {
        // remove semaphores
        $opt = (PHP_VERSION_ID >= 80000) ? true : 1;
        if (function_exists('sem_get')) {
            $sem = sem_get(0x228AAAE7, 1, 0660, $opt);
            if ($sem) { sem_remove($sem); }
        }

        // remove any old op cache
        do_for_each(glob(WAF_ROOT."cache/*.profile", GLOB_NOSORT), 'unlink');
        do_for_each(glob(WAF_ROOT."cache/objects/*", GLOB_NOSORT), 'unlink');

        include_once \BitFire\WAF_ROOT."src/cuckoo.php";
        if (class_exists("\BitFire\Cuckoo")) {
            cuckoo::delete();
        }
    }

    /**
     * get a reference to cache singleton
     * @param null|string $type - default to config value. 'apcu', 'shmop', 'opcache'
     * @return CacheStorage 
     */
    public static function get_instance(?string $type = null) : CacheStorage {
        if (self::$_instance === null || ($type !== null && self::$_type != $type)) {
            $type = (empty($type) ? CFG::str("cache_type", "nop") : $type);
            self::$_instance = new CacheStorage($type);
        }
        return self::$_instance;
    }


    /**
     * set the cache type and create new implementation
     */
    protected function __construct(?string $type = 'nop') {
        if ($type === "apcu" && function_exists('apcu_store')) {
            self::$_type = $type;
        }
        else if ($type === "shmop" && function_exists('shmop_open')) {
            require_once \BitFire\WAF_SRC . "cuckoo.php";
            $this->_shmop = new cuckoo();
            self::$_type = $type;
        }
        else if ($type === "shm" && function_exists('shm_attach')) {
            require_once \BitFire\WAF_SRC . "shmop.php";
            $this->_shm = new shm();
            self::$_type = $type;
        }
        else {
            self::$_type = $type;
        }
    }

    /**
     * @return string opcode cache file path for a given key
     */
    protected function key2name(string $key) : string {
        $dir =  \BitFire\WAF_ROOT . "cache/objects/";
        if (!file_exists($dir)) {
            mkdir($dir, 0775, true);
        }
        return $dir . $key;
    }

    /**
     * save data to key name
     * TODO: add flag for not overwriting important data and not writing transient data to opcache 
     * 32 = CUCKOO_LOW
     */
    public function save_data(string $key_name, $data, int $seconds, int $priority = 32) : bool {
        assert(self::$_type !== null, "must call set_type before using cache");
        if(!is_array($data) && !is_string($data) && !is_null($data)) { debug("store invalid data (%s)[%s]", $key_name, $data); return false; }

        $storage = array($key_name, $data);
        switch (self::$_type) {
            case "shm":
                return $this->_shm->write($key_name, $seconds, $storage);
            case "shmop":
                return $this->_shmop->write($key_name, $seconds, $storage, $priority);
            case "apcu":
                if ($seconds < 1) { return \apcu_delete("_bitfire:$key_name"); }
                return (bool)\apcu_store("_bitfire:$key_name", $storage, $seconds);
            case "opcache":
                // don't op cache metrics
                if (strpos($key_name, "metric") !== false) { return false; }
                $object_file = $this->key2name($key_name);
                if ($data === null) { 
                    if (file_exists($object_file)) {
                        return unlink($this->key2name($key_name));
                    }
                } else {
                    $s = var_export($storage, true);
                    $exp = time() + $seconds; 
                    $data = "<?php \$value = $s; \$priority = $priority; \$success = (time() < $exp);";
                    return file_put_contents($object_file, $data, LOCK_EX) == strlen($data);
                }
            default:
                return false;
        }
    }

    // only lock metrics updates
    public function lock(string $key_name) {
        $sem = null;

        if (strpos($key_name, 'metrics') !== false && function_exists('sem_acquire')) {
            $opt = (PHP_VERSION_ID >= 80000) ? true : 1;
            $sem = sem_get(0x228AAAE7, 1, 0660, $opt);
            if (!sem_acquire($sem, true)) { return null; };
        }
        return $sem;
    }
    
    // unlock the semaphore if it is not null
    public function unlock($sem) {
        if ($sem != null && function_exists('sem_release')) { sem_release(($sem)); }
    }

    /**
     * FIFO buffer or $num_items, ugly, refactor
     */
    public function rotate_data(string $key_name, $data, int $num_items) {
        $sem = $this->lock($key_name);
        $saved = $this->load_data($key_name);
        if (!\is_array($saved)) { $saved = array($data); }
        else { $saved[] = $data; }
        $this->save_data($key_name, array_slice($saved, 0, $num_items), 86400*30);
        $this->unlock($sem);
    }

    /**
     * update cache entry @key_name with result of $fn or $init if it is expired.
     * return the cached item, or if expired, init or $fn
     * @param $fn($data) called with the original value, saves with returned value
     */
    public function update_data(string $key_name, callable $fn, callable $init, int $ttl) {
        $sem = $this->lock($key_name);
        $data = $this->load_data($key_name);
        if ($data === null) { trace("UP_INIT"); $data = $init(); }
        $updated = $fn($data);

        $this->save_data($key_name, $updated, $ttl);
        $this->unlock($sem);
        return $updated;
    }

    public function load_data(string $key_name, $init = null) {
        assert(self::$_type !== null, "must call set_type before using cache");

        $value = null;
        $success = false;

        $t = self::$_type;
        switch (self::$_type) {
            case "shm":
                $tmp = $this->_shm->read($key_name);
                $success = ($tmp !== NULL);
                $value = ($success) ? $tmp : NULL;
                break;
            case "shmop":
                $tmp = $this->_shmop->read($key_name);
                $success = ($tmp !== NULL);
                $value = ($success) ? $tmp : NULL;
                break;
            case "apcu":
                $value = \apcu_fetch("_bitfire:$key_name", $success);
                break;
            case "opcache":
                $file = $this->key2name($key_name);
                if (file_exists($file)) {
                    @include($this->key2name($key_name));
                    // remove expired data
                    if (!$success) {
                        @unlink($file);
                    }
                }
                break;
            default: 
                break;
        }

        if ($success) {
            // load failed
            if (is_bool($value) && !$value) {
                return $init;
            }
            if (isset($value[0]) && $value[0] == $key_name) {
                trace("OK[$key_name]");
                return $value[1];
            }
        }

        trace("MISS[$key_name]");
        return $init;
    }

    /**
     * load the data from cache, else call $generator
     */
    public function load_or_cache(string $key_name, int $ttl, callable $generator) {
        if (($data = $this->load_data($key_name)) === null) {
            $data = $generator();
            assert(is_array($data) || is_string($data), "$key_name generator returned invalid data (" . gettype($data) . ")");
            $this->save_data($key_name, $data, $ttl);
        }
        // assert(is_array($data) || is_string($data), "$key_name cache returned invalid data (" . gettype($data) . ")");
        return $data;
    }

    public function clear_cache() : void {
        switch (self::$_type) {
            case "shmop":
                trace("CLRCX");
                $value = $this->_shmop->clear();
                break;
        }
    }
}