diff --git a/cache/Cache.php b/cache/Cache.php index 672355261..cf5518c2f 100644 --- a/cache/Cache.php +++ b/cache/Cache.php @@ -146,7 +146,7 @@ class SS_Cache { * @param string $frontend (optional) The type of Zend_Cache frontend * @param array $frontendOptions (optional) Any frontend options to use. * - * @return Zend_Cache_Core The cache object + * @return CacheProxy|Zend_Cache_Core */ public static function factory($for, $frontend='Output', $frontendOptions=null) { self::init(); @@ -175,8 +175,8 @@ class SS_Cache { $backend = self::$backends[$backend_name]; $basicOptions = array( - 'cache_id_prefix' => md5(BASE_PATH) . '_' . $for . '_', - ); + 'cache_id_prefix' => md5(BASE_PATH) . '_' . $for . '_', + ); if ($cache_lifetime >= 0) { $basicOptions['lifetime'] = $cache_lifetime; @@ -188,8 +188,14 @@ class SS_Cache { require_once 'Zend/Cache.php'; - return Zend_Cache::factory( + $cache = Zend_Cache::factory( $frontend, $backend[0], $frontendOptions, $backend[1] ); + + if (!empty($frontendOptions['disable-segmentation'])) { + return $cache; + } + + return Injector::inst()->createWithArgs('CacheProxy', array($cache)); } } diff --git a/cache/CacheProxy.php b/cache/CacheProxy.php new file mode 100644 index 000000000..07a75a33e --- /dev/null +++ b/cache/CacheProxy.php @@ -0,0 +1,123 @@ +cache = $cache; + + parent::__construct(); + } + + public function setDirectives($directives) { + $this->cache->setDirectives($directives); + } + + public function setConfig(Zend_Config $config) { + return $this->cache->setConfig($config); + } + + public function setBackend(Zend_Cache_Backend $backendObject) { + return $this->cache->setBackend($backendObject); + } + + public function getBackend() { + return $this->cache->getBackend(); + } + + public function setOption($name, $value) { + $this->cache->setOption($name, $value); + } + + public function getOption($name) { + return $this->cache->getOption($name); + } + + public function setLifetime($newLifetime) { + return $this->cache->setLifetime($newLifetime); + } + + public function getIds() { + return $this->cache->getIds(); + } + + public function getTags() { + return $this->cache->getTags(); + } + + public function getIdsMatchingTags($tags = array()) { + return $this->cache->getIdsMatchingTags($tags); + } + + public function getIdsNotMatchingTags($tags = array()) { + return $this->cache->getIdsNotMatchingTags($tags); + } + + public function getIdsMatchingAnyTags($tags = array()) { + return $this->cache->getIdsMatchingAnyTags($tags); + } + + public function getFillingPercentage() { + return $this->cache->getFillingPercentage(); + } + + public function getMetadatas($id) { + return $this->cache->getMetadatas($this->getKeyID($id)); + } + + public function touch($id, $extraLifetime) { + return $this->cache->touch($this->getKeyID($id), $extraLifetime); + } + + public function load($id, $doNotTestCacheValidity = false, $doNotUnserialize = false) { + return $this->cache->load($this->getKeyID($id), $doNotTestCacheValidity, $doNotUnserialize); + } + + public function test($id) { + return $this->cache->test($this->getKeyID($id)); + } + + public function save($data, $id = null, $tags = array(), $specificLifetime = false, $priority = 8) { + return $this->cache->save( + $data, + $this->getKeyID($id), + $tags, + $specificLifetime, + $priority + ); + } + + public function remove($id) { + return $this->cache->remove($this->getKeyID($id)); + } + + public function clean($mode = Zend_Cache::CLEANING_MODE_ALL, $tags = array()) { + return $this->cache->clean($mode, $tags); + } + + /** + * Creates a dynamic key based on versioned state + * @param string $key + * @return string + */ + protected function getKeyID($key) { + $state = Versioned::get_reading_mode(); + if ($state) { + return $key . '_' . md5($state); + } + return $key; + } +} \ No newline at end of file diff --git a/core/manifest/ConfigManifest.php b/core/manifest/ConfigManifest.php index 6bb5485b3..caf63f716 100644 --- a/core/manifest/ConfigManifest.php +++ b/core/manifest/ConfigManifest.php @@ -110,7 +110,7 @@ class SS_ConfigManifest { /** * Provides a hook for mock unit tests despite no DI - * @return Zend_Cache_Frontend + * @return Zend_Cache_Core */ protected function getCache() { @@ -118,6 +118,7 @@ class SS_ConfigManifest { 'automatic_serialization' => true, 'lifetime' => null, 'cache_id_prefix' => 'SS_Configuration_', + 'disable-segmentation' => true, )); } diff --git a/core/manifest/SilverStripeVersionProvider.php b/core/manifest/SilverStripeVersionProvider.php index c0e6ecf9f..b1cd92426 100644 --- a/core/manifest/SilverStripeVersionProvider.php +++ b/core/manifest/SilverStripeVersionProvider.php @@ -83,7 +83,11 @@ class SilverStripeVersionProvider $lockData = array(); if ($cache) { - $cache = SS_Cache::factory('SilverStripeVersionProvider_composerlock'); + $cache = SS_Cache::factory( + 'SilverStripeVersionProvider_composerlock', + 'Output', + array('disable-segmentation' => true) + ); $cacheKey = filemtime($composerLockPath); if ($versions = $cache->load($cacheKey)) { $lockData = json_decode($versions, true); diff --git a/docs/en/02_Developer_Guides/08_Performance/01_Caching.md b/docs/en/02_Developer_Guides/08_Performance/01_Caching.md index ca76d60da..1a680d0ae 100644 --- a/docs/en/02_Developer_Guides/08_Performance/01_Caching.md +++ b/docs/en/02_Developer_Guides/08_Performance/01_Caching.md @@ -90,6 +90,20 @@ e.g. by including the `LastEdited` value when caching `DataObject` results. // set all caches to 3 hours SS_Cache::set_cache_lifetime('any', 60*60*3); +### Versioned cache segmentation + +`SS_Cache` segments caches based on the versioned reading mode. This prevents developers +from caching draft data and then accidentally exposing it on the live stage without potentially +required authorisation checks. This segmentation is automatic for all caches generated using +`SS_Cache::factory` method. + +Data that is not content sensitive can be cached across stages by simply opting out of the +segmented cache with the `disable-segmentation` argument. + +```php +$cache = SS_Cache::factory('myapp', 'Output', array('disable-segmentation' => true)); +``` + ## Alternative Cache Backends By default, SilverStripe uses a file-based caching backend. diff --git a/docs/en/04_Changelogs/3.7.0.md b/docs/en/04_Changelogs/3.7.0.md new file mode 100644 index 000000000..6dc129cd6 --- /dev/null +++ b/docs/en/04_Changelogs/3.7.0.md @@ -0,0 +1,26 @@ +# 3.7.0 + +### Versioned cache segmentation + +`SS_Cache` now maintains separate cache pools for each versioned stage. This prevents developers from caching draft data and then accidentally exposing it on the live stage without potentially required authorisation checks. Unless you rely on caching across stages, you don't need to change your own code for this change to take effect. Note that cache keys will be internally rewritten, causing any existing cache items to become invalid when this change is deployed. + +```php +// Before: +$cache = SS_Cache::factory('myapp'); +Versioned::set_reading_mode('Stage.Stage'); +$cache->save('Some draft content. Not for public viewing yet.', 'my_key'); +Versioned::set_reading_mode('Stage.Live'); +$cache->load('my_key'); // 'Some draft content. Not for public viewing yet' + +// After: +$cache = SS_Cache::factory('myapp'); +Versioned::set_reading_mode('Stage.Stage'); +$cache->save('Some draft content. Not for public viewing yet.', 'my_key'); +Versioned::set_reading_mode('Stage.Live'); +$cache->load('my_key'); // null +``` +Data that is not content sensitive can be cached across stages by simply opting out of the segmented cache with the `disable-segmentation` argument. + +```php +$cache = SS_Cache::factory('myapp', 'Output', array('disable-segmentation' => true)); +``` diff --git a/filesystem/GD.php b/filesystem/GD.php index 201d49528..4b863c712 100644 --- a/filesystem/GD.php +++ b/filesystem/GD.php @@ -39,7 +39,7 @@ class GDBackend extends SS_Object implements Image_Backend { // If we're working with image resampling, things could take a while. Bump up the time-limit increase_time_limit_to(300); - $this->cache = SS_Cache::factory('GDBackend_Manipulations'); + $this->cache = SS_Cache::factory('GDBackend_Manipulations', 'Output', array('disable-segmentation' => true)); if($filename && is_readable($filename)) { $this->cacheKey = md5(implode('_', array($filename, filemtime($filename)))); diff --git a/i18n/i18n.php b/i18n/i18n.php index 668a8bcaa..c1912fc19 100644 --- a/i18n/i18n.php +++ b/i18n/i18n.php @@ -118,10 +118,18 @@ class i18n extends SS_Object implements TemplateGlobalProvider, Flushable { /** * Return an instance of the cache used for i18n data. - * @return Zend_Cache + * @return Zend_Cache_Core */ public static function get_cache() { - return SS_Cache::factory('i18n', 'Output', array('lifetime' => null, 'automatic_serialization' => true)); + return SS_Cache::factory( + 'i18n', + 'Output', + array( + 'lifetime' => null, + 'automatic_serialization' => true, + 'disable-segmentation' => true, + ) + ); } /** diff --git a/tasks/CleanImageManipulationCache.php b/tasks/CleanImageManipulationCache.php index 03c7c8a29..38d742069 100644 --- a/tasks/CleanImageManipulationCache.php +++ b/tasks/CleanImageManipulationCache.php @@ -35,7 +35,7 @@ class CleanImageManipulationCache extends BuildTask { $images = DataObject::get('Image'); if($images && Image::get_backend() == "GDBackend") { - $cache = SS_Cache::factory('GDBackend_Manipulations'); + $cache = SS_Cache::factory('GDBackend_Manipulations', 'Output', array('disable-segmentation' => true)); foreach($images as $image) { $path = $image->getFullPath(); diff --git a/tests/cache/CacheTest.php b/tests/cache/CacheTest.php index 9ab385df5..4887420e1 100644 --- a/tests/cache/CacheTest.php +++ b/tests/cache/CacheTest.php @@ -2,7 +2,12 @@ class CacheTest extends SapphireTest { - public function testCacheBasics() { + public function setUpOnce() { + parent::setUpOnce(); + Versioned::set_reading_mode('Stage.Live'); + } + + public function testCacheBasics() { $cache = SS_Cache::factory('test'); $cache->save('Good', 'cachekey'); @@ -64,5 +69,73 @@ class CacheTest extends SapphireTest { $this->assertEquals(1200, $cache->getOption('lifetime')); } + public function testVersionedCacheSegmentation() { + $cacheInstance = SS_Cache::factory('versioned'); + $cacheInstance->clean(); + + Versioned::set_reading_mode('Stage.Live'); + $result = $cacheInstance->load('shared_key'); + $this->assertFalse($result); + $cacheInstance->save('uncle', 'shared_key'); + // Shared key is cached on LIVE + $this->assertEquals('uncle', $cacheInstance->load('shared_key')); + + Versioned::set_reading_mode('Stage.Stage'); + + // Shared key does not exist on STAGE + $this->assertFalse($cacheInstance->load('shared_key')); + + $cacheInstance->save('cheese', 'shared_key'); + $cacheInstance->save('bar', 'stage_key'); + + // Shared key has its own value on STAGE + $this->assertEquals('cheese', $cacheInstance->load('shared_key')); + // New key is cached on STAGE + $this->assertEquals('bar', $cacheInstance->load('stage_key')); + + Versioned::set_reading_mode('Stage.Live'); + + // New key does not exist on LIVE + $this->assertFalse($cacheInstance->load('stage_key')); + // Shared key retains its own value on LIVE + $this->assertEquals('uncle', $cacheInstance->load('shared_key')); + + $cacheInstance->clean(); + + } + + public function testDisableVersionedCacheSegmentation() { + $cacheInstance = SS_Cache::factory('versioned_disabled', 'Output', array('disable-segmentation' => true)); + $cacheInstance->clean(); + + Versioned::set_reading_mode('Stage.Live'); + $result = $cacheInstance->load('shared_key'); + $this->assertFalse($result); + $cacheInstance->save('uncle', 'shared_key'); + // Shared key is cached on LIVE + $this->assertEquals('uncle', $cacheInstance->load('shared_key')); + + Versioned::set_reading_mode('Stage.Stage'); + + // Shared key is same on STAGE + $this->assertEquals('uncle', $cacheInstance->load('shared_key')); + + $cacheInstance->save('cheese', 'shared_key'); + $cacheInstance->save('bar', 'stage_key'); + + // Shared key is overwritten on STAGE + $this->assertEquals('cheese', $cacheInstance->load('shared_key')); + // New key is written on STAGE + $this->assertEquals('bar', $cacheInstance->load('stage_key')); + + Versioned::set_reading_mode('Stage.Live'); + // New key has same value on LIVE + $this->assertEquals('bar', $cacheInstance->load('stage_key')); + // New value for existing key is same on LIVE + $this->assertEquals('cheese', $cacheInstance->load('shared_key')); + + $cacheInstance->clean(); + } + } diff --git a/tests/model/DataObjectDuplicationTest.php b/tests/model/DataObjectDuplicationTest.php index 2fcab1c4d..d058fafbb 100644 --- a/tests/model/DataObjectDuplicationTest.php +++ b/tests/model/DataObjectDuplicationTest.php @@ -111,13 +111,21 @@ class DataObjectDuplicationTest extends SapphireTest { "Match between relation of copy and the original"); $this->assertEquals(0, $oneCopy->twos()->Count(), "Many-to-one relation not copied (has_many)"); - $this->assertEquals($three->ID, $oneCopy->threes()->First()->ID, - "Match between relation of copy and the original"); - $this->assertEquals($one->ID, $threeCopy->ones()->First()->ID, - "Match between relation of copy and the original"); - - $this->assertEquals('three', $oneCopy->threes()->First()->TestExtra, - "Match between extra field of copy and the original"); + $this->assertContains( + $three->ID, + $oneCopy->threes()->column('ID'), + "Match between relation of copy and the original" + ); + $this->assertContains( + $one->ID, + $threeCopy->ones()->column('ID'), + "Match between relation of copy and the original" + ); + $this->assertContains( + 'three', + $oneCopy->threes()->column('TestExtra'), + "Match between extra field of copy and the original" + ); } } diff --git a/view/SSViewer.php b/view/SSViewer.php index c78b05fc9..85a540d92 100644 --- a/view/SSViewer.php +++ b/view/SSViewer.php @@ -31,10 +31,10 @@ class SSViewer_Scope { const UP_INDEX = 4; const CURRENT_INDEX = 5; const ITEM_OVERLAY = 6; - + // The stack of previous "global" items // An indexed array of item, item iterator, item iterator total, pop index, up index, current index & parent overlay - private $itemStack = array(); + private $itemStack = array(); // The current "global" item (the one any lookup starts from) protected $item; @@ -1016,6 +1016,14 @@ class SSViewer implements Flushable { } } + /** + * @return Zend_Cache_Core + */ + protected static function defaultPartialCacheStore() + { + return SS_Cache::factory('cacheblock', 'Output', array('disable-segmentation' => true)); + } + /** * Call this to disable rewriting of links. This is useful in Ajax applications. * It returns the SSViewer objects, so that you can call new SSViewer("X")->dontRewriteHashlinks()->process(); @@ -1082,7 +1090,7 @@ class SSViewer implements Flushable { */ public static function flush_cacheblock_cache($force = false) { if (!self::$cacheblock_cache_flushed || $force) { - $cache = SS_Cache::factory('cacheblock'); + $cache = self::defaultPartialCacheStore(); $backend = $cache->getBackend(); if( @@ -1120,7 +1128,7 @@ class SSViewer implements Flushable { * @return Zend_Cache_Core */ public function getPartialCacheStore() { - return $this->partialCacheStore ? $this->partialCacheStore : SS_Cache::factory('cacheblock'); + return $this->partialCacheStore ? $this->partialCacheStore : self::defaultPartialCacheStore(); } /**