diff --git a/app/Settings/SettingService.php b/app/Settings/SettingService.php index d1bac164d..9bcd6c7ec 100644 --- a/app/Settings/SettingService.php +++ b/app/Settings/SettingService.php @@ -3,45 +3,29 @@ namespace BookStack\Settings; use BookStack\Auth\User; -use Illuminate\Contracts\Cache\Repository as Cache; /** * Class SettingService * The settings are a simple key-value database store. * For non-authenticated users, user settings are stored via the session instead. + * A local array-based cache is used to for setting accesses across a request. */ class SettingService { - protected Setting $setting; - protected Cache $cache; protected array $localCache = []; - protected string $cachePrefix = 'setting-'; - - public function __construct(Setting $setting, Cache $cache) - { - $this->setting = $setting; - $this->cache = $cache; - } /** * Gets a setting from the database, * If not found, Returns default, Which is false by default. */ - public function get(string $key, $default = null) + public function get(string $key, $default = null): mixed { if (is_null($default)) { $default = config('setting-defaults.' . $key, false); } - if (isset($this->localCache[$key])) { - return $this->localCache[$key]; - } - $value = $this->getValueFromStore($key) ?? $default; - $formatted = $this->formatValue($value, $default); - $this->localCache[$key] = $formatted; - - return $formatted; + return $this->formatValue($value, $default); } /** @@ -79,52 +63,78 @@ class SettingService } /** - * Gets a setting value from the cache or database. - * Looks at the system defaults if not cached or in database. - * Returns null if nothing is found. + * Gets a setting value from the local cache. + * Will load the local cache if not previously loaded. */ - protected function getValueFromStore(string $key) + protected function getValueFromStore(string $key): mixed { - // Check the cache - $cacheKey = $this->cachePrefix . $key; - $cacheVal = $this->cache->get($cacheKey, null); - if ($cacheVal !== null) { - return $cacheVal; + $cacheCategory = $this->localCacheCategory($key); + if (!isset($this->localCache[$cacheCategory])) { + $this->loadToLocalCache($cacheCategory); } - // Check the database - $settingObject = $this->getSettingObjectByKey($key); - if ($settingObject !== null) { - $value = $settingObject->value; - - if ($settingObject->type === 'array') { - $value = json_decode($value, true) ?? []; - } - - $this->cache->forever($cacheKey, $value); - - return $value; - } - - return null; + return $this->localCache[$cacheCategory][$key] ?? null; } /** - * Clear an item from the cache completely. + * Put the given value into the local cached under the given key. */ - protected function clearFromCache(string $key) + protected function putValueIntoLocalCache(string $key, mixed $value): void { - $cacheKey = $this->cachePrefix . $key; - $this->cache->forget($cacheKey); - if (isset($this->localCache[$key])) { - unset($this->localCache[$key]); + $cacheCategory = $this->localCacheCategory($key); + if (!isset($this->localCache[$cacheCategory])) { + $this->loadToLocalCache($cacheCategory); + } + + $this->localCache[$cacheCategory][$key] = $value; + } + + /** + * Get the category for the given setting key. + * Will return 'app' for a general app setting otherwise 'user:' for a user setting. + */ + protected function localCacheCategory(string $key): string + { + if (str_starts_with($key, 'user:')) { + return implode(':', array_slice(explode(':', $key), 0, 2)); + } + + return 'app'; + } + + /** + * For the given category, load the relevant settings from the database into the local cache. + */ + protected function loadToLocalCache(string $cacheCategory): void + { + $query = Setting::query(); + + if ($cacheCategory === 'app') { + $query->where('setting_key', 'not like', 'user:%'); + } else { + $query->where('setting_key', 'like', $cacheCategory . ':%'); + } + $settings = $query->toBase()->get(); + + if (!isset($this->localCache[$cacheCategory])) { + $this->localCache[$cacheCategory] = []; + } + + foreach ($settings as $setting) { + $value = $setting->value; + + if ($setting->type === 'array') { + $value = json_decode($value, true) ?? []; + } + + $this->localCache[$cacheCategory][$setting->setting_key] = $value; } } /** * Format a settings value. */ - protected function formatValue($value, $default) + protected function formatValue(mixed $value, mixed $default): mixed { // Change string booleans to actual booleans if ($value === 'true') { @@ -155,21 +165,22 @@ class SettingService * Add a setting to the database. * Values can be an array or a string. */ - public function put(string $key, $value): bool + public function put(string $key, mixed $value): bool { - $setting = $this->setting->newQuery()->firstOrNew([ + $setting = Setting::query()->firstOrNew([ 'setting_key' => $key, ]); + $setting->type = 'string'; + $setting->value = $value; if (is_array($value)) { $setting->type = 'array'; - $value = $this->formatArrayValue($value); + $setting->value = $this->formatArrayValue($value); } - $setting->value = $value; $setting->save(); - $this->clearFromCache($key); + $this->putValueIntoLocalCache($key, $value); return true; } @@ -209,7 +220,7 @@ class SettingService * Can only take string value types since this may use * the session which is less flexible to data types. */ - public function putForCurrentUser(string $key, string $value) + public function putForCurrentUser(string $key, string $value): bool { return $this->putUser(user(), $key, $value); } @@ -231,15 +242,19 @@ class SettingService if ($setting) { $setting->delete(); } - $this->clearFromCache($key); + + $cacheCategory = $this->localCacheCategory($key); + if (isset($this->localCache[$cacheCategory])) { + unset($this->localCache[$cacheCategory][$key]); + } } /** * Delete settings for a given user id. */ - public function deleteUserSettings(string $userId) + public function deleteUserSettings(string $userId): void { - return $this->setting->newQuery() + Setting::query() ->where('setting_key', 'like', $this->userKey($userId) . '%') ->delete(); } @@ -249,7 +264,16 @@ class SettingService */ protected function getSettingObjectByKey(string $key): ?Setting { - return $this->setting->newQuery() - ->where('setting_key', '=', $key)->first(); + return Setting::query() + ->where('setting_key', '=', $key) + ->first(); + } + + /** + * Empty the local setting value cache used by this service. + */ + public function flushCache(): void + { + $this->localCache = []; } } diff --git a/tests/Commands/UpdateUrlCommandTest.php b/tests/Commands/UpdateUrlCommandTest.php index c07a80312..1788e9452 100644 --- a/tests/Commands/UpdateUrlCommandTest.php +++ b/tests/Commands/UpdateUrlCommandTest.php @@ -39,6 +39,8 @@ class UpdateUrlCommandTest extends TestCase setting()->put('my-custom-item', 'https://example.com/donkey/cat'); $this->runUpdate('https://example.com', 'https://cats.example.com'); + setting()->flushCache(); + $settingVal = setting('my-custom-item'); $this->assertEquals('https://cats.example.com/donkey/cat', $settingVal); } @@ -47,6 +49,9 @@ class UpdateUrlCommandTest extends TestCase { setting()->put('my-custom-array-item', [['name' => 'a https://example.com/donkey/cat url']]); $this->runUpdate('https://example.com', 'https://cats.example.com'); + + setting()->flushCache(); + $settingVal = setting('my-custom-array-item'); $this->assertEquals('a https://cats.example.com/donkey/cat url', $settingVal[0]['name']); }