From 71724582928190ecd5e0e9169a8eab557d8cd1bc Mon Sep 17 00:00:00 2001
From: Jan-Hendrik Willms <tleilax+studip@gmail.com>
Date: Tue, 20 Dec 2022 13:18:31 +0000
Subject: [PATCH] allow StudipCachedArray to expire completely and use that in
 RolePersistence, fixes #1580

Closes #1580

Merge request studip/studip!1009
---
 lib/classes/StudipCachedArray.php             | 62 +++++++++++++++----
 lib/plugins/db/RolePersistence.class.php      | 54 +++++++++++++---
 .../lib/classes/StudipCachedArrayTest.php     | 15 +++++
 3 files changed, 113 insertions(+), 18 deletions(-)

diff --git a/lib/classes/StudipCachedArray.php b/lib/classes/StudipCachedArray.php
index 221b76c3aaf..830128dde4d 100644
--- a/lib/classes/StudipCachedArray.php
+++ b/lib/classes/StudipCachedArray.php
@@ -15,21 +15,24 @@ class StudipCachedArray implements ArrayAccess
 
     protected $data = [];
 
+    protected $hash;
+
     /**
      * Constructs the cached array
      *
-     * @param string $key    Cache key where the array is/should be stored
-     *                       an int which will be length of the substring
-     *                       of the given chache offset or a callable which
-     *                       will return the partition key.
-     * @param int $duration  Duration in seconds for which the item shall be
-     *                       stored
+     * @param string $key      Cache key where the array is/should be stored
+     *                         an int which will be length of the substring
+     *                         of the given chache offset or a callable which
+     *                         will return the partition key.
+     * @param int    $duration Duration in seconds for which the item shall be
+     *                         stored
      */
     public function __construct(string $key, int $duration = StudipCache::DEFAULT_EXPIRATION)
     {
-        $this->key      = self::class . "/{$key}";
-        $this->cache    = StudipCacheFactory::getCache();
+        $this->key = self::class . "/{$key}";
+        $this->cache = StudipCacheFactory::getCache();
         $this->duration = $duration;
+        $this->hash = $this->getHash();
 
         $this->reset();
     }
@@ -42,10 +45,20 @@ class StudipCachedArray implements ArrayAccess
         $this->data = [];
     }
 
+    /**
+     * Removes all values from the cache.
+     */
+    public function expire(): void
+    {
+        $this->hash = $this->getHash(true);
+        $this->reset();
+    }
+
     /**
      * Determines whether an offset exists in the array.
      *
      * @param string $offset Offset
+     *
      * @return bool
      */
     public function offsetExists($offset): bool
@@ -58,6 +71,7 @@ class StudipCachedArray implements ArrayAccess
      * Returns the value at given offset or null if it doesn't exist.
      *
      * @param string $offset Offset
+     *
      * @return mixed
      */
     public function offsetGet($offset)
@@ -75,7 +89,7 @@ class StudipCachedArray implements ArrayAccess
     public function offsetSet($offset, $value): void
     {
         if ($offset === null) {
-            throw new Exception('Cannot push to cached array, use StudipCachedArray instead');
+            throw new Exception('Cannot push to cached array, use correct offset instead');
         }
 
         if (!isset($this->data[$offset]) || $this->data[$offset] !== $value) {
@@ -120,9 +134,11 @@ class StudipCachedArray implements ArrayAccess
      */
     protected function storeData(string $offset): void
     {
+        $data = $this->swapNullAndFalse($this->data[$offset]);
+
         $this->cache->write(
             $this->getCacheKey($offset),
-            $this->swapNullAndFalse($this->data[$offset]),
+            $data,
             $this->duration
         );
     }
@@ -131,11 +147,18 @@ class StudipCachedArray implements ArrayAccess
      * Returns the cache key for a specific offset.
      *
      * @param string $offset Offset of the cached item
+     *
      * @return string
      */
     private function getCacheKey(string $offset): string
     {
-        return rtrim($this->key, '/') . "/{$offset}";
+        $key = rtrim($this->key, '/');
+        if ($this->hash) {
+            $key .= "/{$this->hash}";
+        }
+        $key .= "/{$offset}";
+
+        return $key;
     }
 
     /**
@@ -158,4 +181,21 @@ class StudipCachedArray implements ArrayAccess
 
         return $value;
     }
+
+    /**
+     * Loads or creates and stores a hash for this cached array.
+     *
+     * @return string
+     */
+    private function getHash(bool $recreate = false): string
+    {
+        if (!$recreate) {
+            $hash = $this->cache->read($this->key);
+            return $hash === false ? '' : $hash;
+        }
+
+        $hash = md5(uniqid(__CLASS__, true));
+        $this->cache->write($this->key, $hash);
+        return $hash;
+    }
 }
diff --git a/lib/plugins/db/RolePersistence.class.php b/lib/plugins/db/RolePersistence.class.php
index 1b03a0132c9..ff17a94d0bd 100644
--- a/lib/plugins/db/RolePersistence.class.php
+++ b/lib/plugins/db/RolePersistence.class.php
@@ -129,9 +129,10 @@ class RolePersistence
 
         // sweep roles cache
         self::expireRolesCache();
+        self::expireUserCache();
 
         foreach ($statement as $plugin_id) {
-            unset(self::getPluginRolesCache()[$plugin_id]);
+            self::expirePluginCache($plugin_id);
         }
 
         NotificationCenter::postNotification('RoleDidDelete', $id, $name);
@@ -338,7 +339,7 @@ class RolePersistence
             $statement->execute();
         }
 
-        unset(self::getPluginRolesCache()[$plugin_id]);
+        self::expirePluginCache($plugin_id);
 
         foreach ($role_ids as $role_id) {
             NotificationCenter::postNotification(
@@ -370,7 +371,7 @@ class RolePersistence
             $statement->execute();
         }
 
-        unset(self::getPluginRolesCache()[$plugin_id]);
+        self::expirePluginCache($plugin_id);
 
         foreach ($role_ids as $role_id) {
             NotificationCenter::postNotification(
@@ -488,7 +489,7 @@ class RolePersistence
     private static $user_roles_cache = null;
     private static $plugin_roles_cache = null;
 
-    private static function getUserRolesCache()
+    private static function getUserRolesCache(): StudipCachedArray
     {
         if (self::$user_roles_cache === null) {
             self::$user_roles_cache = new StudipCachedArray(self::USER_ROLES_CACHE_KEY);
@@ -496,7 +497,7 @@ class RolePersistence
         return self::$user_roles_cache;
     }
 
-    private static function getPluginRolesCache()
+    private static function getPluginRolesCache(): StudipCachedArray
     {
         if (self::$plugin_roles_cache === null) {
             self::$plugin_roles_cache = new StudipCachedArray(self::PLUGIN_ROLES_CACHE_KEY);
@@ -504,13 +505,52 @@ class RolePersistence
         return self::$plugin_roles_cache;
     }
 
+    /**
+     * Expires all cached roles.
+     */
     public static function expireRolesCache()
     {
         StudipCacheFactory::getCache()->expire(self::ROLES_CACHE_KEY);
     }
 
-    public static function expireUserCache($user_id)
+    /**
+     * Expires all cached user role assignments.
+     *
+     * @param string|null $user_id Optional user id to expire the cache for.
+     *                             If none is given, the whole cache is cleared.
+     */
+    public static function expireUserCache($user_id = null)
     {
-        unset(self::getUserRolesCache()[$user_id]);
+        if ($user_id === null) {
+            self::getUserRolesCache()->expire();
+        } else {
+            unset(self::getUserRolesCache()[$user_id]);
+        }
+    }
+
+    /**
+     * Expires all cached plugin role assignments.
+     *
+     * @param string|int|null $plugin_id Optional plugin id to expire the cache
+     *                                   for. If none is given, the whole cache
+     *                                   is cleared.
+     */
+    public static function expirePluginCache($plugin_id = null)
+    {
+        if ($plugin_id === null) {
+            self::getPluginRolesCache()->expire();
+        } else {
+            unset(self::getPluginRolesCache()[$plugin_id]);
+        }
+    }
+
+    /**
+     * Expires all caches
+     */
+    public static function expireCaches(): void
+    {
+        self::expireRolesCache();
+        self::expireUserCache();
+        self::expirePluginCache();
     }
 }
diff --git a/tests/unit/lib/classes/StudipCachedArrayTest.php b/tests/unit/lib/classes/StudipCachedArrayTest.php
index e473dc63143..c98c1bd6b5c 100644
--- a/tests/unit/lib/classes/StudipCachedArrayTest.php
+++ b/tests/unit/lib/classes/StudipCachedArrayTest.php
@@ -48,6 +48,21 @@ class StudipCachedArrayTest extends \Codeception\Test\Unit
         $this->assertFalse(isset($cache[$key]));
     }
 
+    /**
+     * @depends testStorage
+     * @dataProvider StorageProvider
+     */
+    public function testExpiration($key, $value)
+    {
+        $cache = $this->getCachedArray();
+
+        $cache[$key] = $value;
+
+        $cache->expire();
+
+        $this->assertFalse(isset($cache[$key]));
+    }
+
     public function StorageProvider(): array
     {
         return [
-- 
GitLab