diff --git a/.github/workflows/php83.yml b/.github/workflows/php83.yml index 20b2f47e..24b9d47c 100644 --- a/.github/workflows/php83.yml +++ b/.github/workflows/php83.yml @@ -4,7 +4,7 @@ on: push: branches: [ master, dev ] pull_request: - branches: [ master ] + branches: [ master, dev ] jobs: diff --git a/phpunit.xml b/phpunit.xml index cf553cde..0ed2e986 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -28,6 +28,10 @@ ./webfiori/framework/router/RouterUri.php ./webfiori/framework/router/Router.php + ./webfiori/framework/cache/AbstractCacheStore.php + ./webfiori/framework/cache/FileCacheStore.php + ./webfiori/framework/cache/Cache.php + ./webfiori/framework/session/Session.php ./webfiori/framework/session/SessionsManager.php ./webfiori/framework/session/DefaultSessionStorage.php @@ -109,6 +113,8 @@ ./tests/webfiori/framework/test/session - + + ./tests/webfiori/framework/test/cache + \ No newline at end of file diff --git a/tests/webfiori/framework/test/cache/CacheTest.php b/tests/webfiori/framework/test/cache/CacheTest.php new file mode 100644 index 00000000..f65ef664 --- /dev/null +++ b/tests/webfiori/framework/test/cache/CacheTest.php @@ -0,0 +1,87 @@ +assertEquals('This is a test.', $data); + $this->assertEquals('This is a test.', Cache::get($key)); + $this->assertNull(Cache::get('not_cached')); + } + /** + * @test + */ + public function test01() { + $key = 'test_2'; + $this->assertFalse(Cache::has($key)); + $data = Cache::get($key, function () { + return 'This is a test.'; + }, 5); + $this->assertEquals('This is a test.', $data); + $this->assertTrue(Cache::has($key)); + sleep(6); + $this->assertFalse(Cache::has($key)); + $this->assertNull(Cache::get($key)); + } + /** + * @test + */ + public function test03() { + $key = 'ok_test'; + $this->assertFalse(Cache::has($key)); + $data = Cache::get($key, function () { + return 'This is a test.'; + }, 600); + $this->assertEquals('This is a test.', $data); + $this->assertTrue(Cache::has($key)); + Cache::delete($key); + $this->assertFalse(Cache::has($key)); + $this->assertNull(Cache::get($key)); + } + /** + * @test + */ + public function test04() { + $key = 'test_3'; + $this->assertFalse(Cache::has($key)); + $data = Cache::get($key, function () { + return 'This is a test.'; + }, 600); + $this->assertEquals('This is a test.', $data); + $item = Cache::getItem($key); + $this->assertNotNull($item); + $this->assertEquals(600, $item->getTTL()); + Cache::setTTL($key, 1000); + $item = Cache::getItem($key); + $this->assertEquals(1000, $item->getTTL()); + Cache::delete($key); + $this->assertNull(Cache::getItem($key)); + } + public function test05() { + $keys = []; + for ($x = 0 ; $x < 10 ; $x++) { + $key = 'item_'.$x; + Cache::get($key, function () { + return 'This is a test.'; + }, 600); + $keys[] = $key; + } + foreach ($keys as $key) { + $this->assertTrue(Cache::has($key)); + } + Cache::flush(); + foreach ($keys as $key) { + $this->assertFalse(Cache::has($key)); + } + } +} diff --git a/webfiori/framework/cache/Cache.php b/webfiori/framework/cache/Cache.php new file mode 100644 index 00000000..f5d1bf86 --- /dev/null +++ b/webfiori/framework/cache/Cache.php @@ -0,0 +1,172 @@ +delete($key); + } + /** + * Removes all items from the cache. + */ + public static function flush() { + self::getDriver()->flush(); + } + /** + * Returns or creates a cache item given its key. + * + * + * @param string $key The unique identifier of the item. + * + * @param callable $generator A callback which is used as a fallback to + * create new cache entry or re-create an existing one if it was expired. + * This callback must return the data that will be cached. + * + * @param int $ttl Time to live of the item in seconds. + * + * @param array $params Any additional parameters to be passed to the callback + * which is used to generate cache data. + * @return null + */ + public static function get(string $key, callable $generator = null, int $ttl = 60, array $params = []) { + $data = self::getDriver()->read($key); + + if ($data !== null && $data !== false) { + return $data; + } + + if (!is_callable($generator)) { + return null; + } + $newData = call_user_func_array($generator, $params); + $item = new Item($key, $newData, $ttl, defined('CACHE_SECRET') ? CACHE_SECRET : ''); + self::getDriver()->cache($item); + + return $newData; + } + /** + * Returns storage engine which is used to store, read, update and delete items + * from the cache. + * + * @return Storage + */ + public static function getDriver() : Storage { + return self::getInst()->driver; + } + /** + * Reads an item from the cache and return its information. + * + * @param string $key The unique identifier of the item. + * + * @return Item|null If such item exist and not yet expired, an object + * of type 'Item' is returned which has all cached item information. Other + * than that, null is returned. + */ + public static function getItem(string $key) { + return self::getDriver()->readItem($key); + } + /** + * Checks if the cache has in item given its unique identifier. + * + * @param string $key + * + * @return bool If the item exist and is not yet expired, true is returned. + * Other than that, false is returned. + */ + public static function has(string $key) : bool { + return self::getDriver()->has($key); + } + /** + * Creates new item in the cache. + * + * Note that the item will only be added if it does not exist or already + * expired or the override option is set to true in case it was already + * created and not expired. + * + * @param string $key The unique identifier of the item. + * + * @param mixed $data The data that will be cached. + * + * @param int $ttl The time at which the data will be kept in the cache (in seconds). + * + * @param bool $override If cache item already exist which has given key and not yet + * expired and this one is set to true, the existing item will be overridden by + * provided data and ttl. + * + * @return bool If successfully added, the method will return true. False + * otherwise. + */ + public static function set(string $key, $data, int $ttl = 60, bool $override = false) : bool { + if (!self::has($key) || $override === true) { + $item = new Item($key, $data, $ttl, defined('CACHE_SECRET') ? CACHE_SECRET : ''); + self::getDriver()->cache($item); + } + + return false; + } + /** + * Sets storage engine which is used to store, read, update and delete items + * from the cache. + * + * @param Storage $driver + */ + public static function setDriver(Storage $driver) { + self::getInst()->driver = $driver; + } + /** + * Updates TTL of specific cache item. + * + * @param string $key The unique identifier of the item. + * + * @param int $ttl The new value for TTL. + * + * @return bool If item is updated, true is returned. Other than that, false + * is returned. + */ + public static function setTTL(string $key, int $ttl) { + $item = self::getItem($key); + + if ($item === null) { + return false; + } + $item->setTTL($ttl); + self::getDriver()->cache($item); + + return true; + } + /** + * Creates and returns a single instance of the class. + * + * @return Cache + */ + private static function getInst() : Cache { + if (self::$inst === null) { + self::$inst = new Cache(); + self::setDriver(new FileStorage()); + } + + return self::$inst; + } +} diff --git a/webfiori/framework/cache/FileStorage.php b/webfiori/framework/cache/FileStorage.php new file mode 100644 index 00000000..17b40b5f --- /dev/null +++ b/webfiori/framework/cache/FileStorage.php @@ -0,0 +1,158 @@ +setPath($path); + } + /** + * Store an item into the cache. + * + * @param Item $item An item that will be added to the cache. + */ + public function cache(Item $item) { + $filePath = $this->getPath().DS.md5($item->getKey()).'.cache'; + $encryptedData = $item->getDataEncrypted(); + + if (!is_dir($this->getPath())) { + mkdir($this->getPath(), 0755, true); + } + file_put_contents($filePath, serialize([ + 'data' => $encryptedData, + 'created_at' => time(), + 'ttl' => $item->getTTL(), + 'expires' => $item->getExpiryTime(), + 'key' => $item->getKey() + ])); + } + /** + * Removes an item from the cache. + * + * @param string $key The key of the item. + */ + public function delete(string $key) { + $filePath = $this->getPath().md5($key).'.cache'; + + if (file_exists($filePath)) { + unlink($filePath); + } + } + /** + * Removes all cached items. + * + */ + public function flush() { + $files = glob($this->cacheDir.'*.cache'); + + foreach ($files as $file) { + unlink($file); + } + } + /** + * Returns a string that represents the path to the folder which is used to + * create cache files. + * + * @return string A string that represents the path to the folder which is used to + * create cache files. + */ + public function getPath() : string { + return $this->cacheDir; + } + /** + * Checks if an item exist in the cache. + * @param string $key The value of item key. + * + * @return bool Returns true if given + * key exist in the cache and not yet expired. + */ + public function has(string $key): bool { + return $this->read($key) !== null; + } + /** + * Reads and returns the data stored in cache item given its key. + * + * @param string $key The key of the item. + * + * @return mixed|null If cache item is not expired, its data is returned. Other than + * that, null is returned. + */ + public function read(string $key) { + $item = $this->readItem($key); + + if ($item !== null) { + return $item->getDataDecrypted(); + } + + return null; + } + /** + * Reads cache item as an object given its key. + * + * @param string $key The unique identifier of the item. + * + * @return Item|null If cache item exist and is not expired, + * an object of type 'Item' is returned. Other than + * that, null is returned. + */ + public function readItem(string $key) { + $this->initData($key); + $now = time(); + + if ($now > $this->data['expires']) { + $this->delete($key); + + return null; + } + $item = new Item($key, $this->data['data'], $this->data['ttl'], defined('CACHE_SECRET') ? CACHE_SECRET : ''); + $item->setCreatedAt($this->data['created_at']); + + return $item; + } + /** + * Sets the path to the folder which is used to create cache files. + * + * @param string $path + */ + public function setPath(string $path) { + $this->cacheDir = $path; + } + private function initData(string $key) { + $filePath = $this->cacheDir.md5($key).'.cache'; + + if (!file_exists($filePath)) { + $this->data = [ + 'expires' => 0, + 'ttl' => 0, + 'data' => null, + 'created_at' => 0, + 'key' => '' + ]; + + return ; + } + + $this->data = unserialize(file_get_contents($filePath)); + } +} diff --git a/webfiori/framework/cache/Item.php b/webfiori/framework/cache/Item.php new file mode 100644 index 00000000..c4a88789 --- /dev/null +++ b/webfiori/framework/cache/Item.php @@ -0,0 +1,196 @@ +setKey($key); + $this->setTTL($ttl); + $this->setData($data); + $this->setSecret($secretKey); + $this->setCreatedAt(time()); + } + /** + * Generates a cryptographic secure key. + * + * The generated key can be used to encrypt sensitive data. + * + * @return string + */ + public static function generateKey() : string { + return bin2hex(random_bytes(32)); + } + /** + * Returns the time at which the item was created at. + * + * The value returned by the method is Unix timestamp. + * + * @return int An integer that represents Unix timestamp in seconds. + */ + public function getCreatedAt() : int { + return $this->createdAt; + } + /** + * Returns the data of cache item. + * + * @return mixed + */ + public function getData() { + return $this->data; + } + /** + * Returns cache item data after performing decryption on it. + * + * @return string + */ + public function getDataDecrypted() : string { + return $this->decrypt($this->getData()); + } + /** + * Returns cache data after performing encryption on it. + * + * Note that the raw data must be + * + * @return string + */ + public function getDataEncrypted() : string { + return $this->encrypt($this->getData()); + } + /** + * Returns the time at which cache item will expire as Unix timestamp. + * + * The method will add the time at which the item was created at to TTL and + * return the value. + * + * @return int The time at which cache item will expire as Unix timestamp. + */ + public function getExpiryTime() : int { + return $this->getCreatedAt() + $this->getTTL(); + } + /** + * Gets the key of the item. + * + * The key acts as a unique identifier for cache items. + * + * @return string A string that represents the key. + */ + public function getKey() : string { + return $this->key; + } + /** + * Returns the value of the key which is used in encrypting cache data. + * + * @return string The value of the key which is used in encrypting cache data. + * Default return value is empty string. + */ + public function getSecret() : string { + return $this->secretKey; + } + /** + * Returns the duration at which the item will be kept in cache in seconds. + * + * @return int The duration at which the item will be kept in cache in seconds. + */ + public function getTTL() : int { + return $this->timeToLive; + } + /** + * Sets the time at which the item was created at. + * + * @param int $time An integer that represents Unix timestamp in seconds. + * Must be a positive value. + */ + public function setCreatedAt(int $time) { + if ($time > 0) { + $this->createdAt = $time; + } + } + /** + * Sets the data of the item. + * + * This represents the data that will be stored or retrieved. + * + * @param mixed $data + */ + public function setData($data) { + $this->data = $data; + } + /** + * Sets the key of the item. + * + * The key acts as a unique identifier for cache items. + * + * @param string $key A string that represents the key. + */ + public function setKey(string $key) { + $this->key = $key; + } + /** + * Sets the value of the key which is used in encrypting cache data. + * + * @param string $secret A cryptographic key which is used to encrypt + * cache data. To generate one, the method Item::generateKey() can be used. + */ + public function setSecret(string $secret) { + $this->secretKey = $secret; + } + /** + * Sets the duration at which the item will be kept in cache in seconds. + * + * @param int $ttl Time-to-live of the item in cache. + */ + public function setTTL(int $ttl) { + if ($ttl > 0) { + $this->timeToLive = $ttl; + } + } + + + private function decrypt($data) { + // decode > extract iv > decrypt + $decodedData = base64_decode($data); + $ivLength = openssl_cipher_iv_length('aes-256-cbc'); + $iv = substr($decodedData, 0, $ivLength); + $encryptedData = substr($decodedData, $ivLength); + $decrypted = openssl_decrypt($encryptedData, 'aes-256-cbc', $this->getSecret(), 0, $iv); + + return $decrypted; + } + private function encrypt($data) { + // iv > encrypt > append iv > encode + $iv = random_bytes(openssl_cipher_iv_length('aes-256-cbc')); + $encryptedData = openssl_encrypt($data, 'aes-256-cbc', $this->getSecret(), 0, $iv); + $encoded = base64_encode($iv.$encryptedData); + + return $encoded; + } +} diff --git a/webfiori/framework/cache/Storage.php b/webfiori/framework/cache/Storage.php new file mode 100644 index 00000000..e9a87063 --- /dev/null +++ b/webfiori/framework/cache/Storage.php @@ -0,0 +1,81 @@ + + *
  • key
  • + *
  • data
  • + *
  • time to live
  • + *
  • creation time
  • + * + * + * @param Item $item An item that will be added to the cache. + */ + public function cache(Item $item); + /** + * Removes an item from the cache. + * + * @param string $key The key of the item. + */ + public function delete(string $key); + /** + * Removes all cached items. + * + * This method must be implemented in a way that it removes all cache items + * regardless of expiry time. + */ + public function flush(); + /** + * Checks if an item exist in the cache. + * + * This method must be implemented in a way that it returns true if given + * key exist in the cache and not yet expired. + * + * @param string $key The value of item key. + * + * @return bool Returns true if given + * key exist in the cache and not yet expired. + */ + public function has(string $key) : bool; + /** + * Reads and returns the data stored in cache item given its key. + * + * This method should be implemented in a way that it reads cache item + * as an object of type 'Item'. Then it should do a check if the cached + * item is expired or not. If not expired, its data is returned. Other than + * that, null should be returned. + * + * @param string $key The key of the item. + * + * @return mixed|null If cache item is not expired, its data is returned. Other than + * that, null is returned. + */ + public function read(string $key); + /** + * Reads cache item as an object given its key. + * + * @param string $key The unique identifier of the item. + * + * @return Item|null If cache item exist and is not expired, + * an object of type 'Item' should be returned. Other than + * that, null is returned. + */ + public function readItem(string $key); +}