Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions src/Extensions/FileStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@
use Illuminate\Cache\FileStore as LaravelFileStore;
use Illuminate\Contracts\Cache\Store;
use Statamic\Support\Str;
use Throwable;

class FileStore extends LaravelFileStore implements Store
{
/**
* The expiration Laravel writes for items stored forever.
*/
private const FOREVER = '9999999999';

private ?string $dir = null;

public function path($key)
Expand All @@ -31,4 +37,57 @@ private function dir()
? $this->directory.'/'
: $this->directory.'/stache/';
}

/**
* Retrieve an item from the cache by key.
*
* Behaves like the parent, but the Stache reads far more often than it writes and
* almost everything it stores never expires. So this opens the file directly rather
* than checking it exists first, and only checks the clock for items that can expire.
*/
public function get($key)
{
if (! $handle = @fopen($this->path($key), 'rb')) {
return null;
}

try {
// Like the parent, take a shared lock so we never read a file mid-write.
if (! flock($handle, LOCK_SH)) {
return null;
}

$contents = stream_get_contents($handle);

flock($handle, LOCK_UN);
} finally {
fclose($handle);
}

$expire = substr($contents, 0, 10);

if ($expire !== self::FOREVER && $this->currentTime() >= (int) $expire) {
$this->forget($key);

return null;
}

try {
return $this->unserializeValue(substr($contents, 10));
} catch (Throwable) {
$this->forget($key);

return null;
}
}

private function unserializeValue(string $value)
{
// Laravel only supports restricting unserializable classes in newer versions.
if (method_exists(LaravelFileStore::class, 'unserialize')) {
return $this->unserialize($value);
}

return unserialize($value);
}
}
103 changes: 103 additions & 0 deletions tests/Extensions/FileStoreTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,35 @@
namespace Tests\Extensions;

use Illuminate\Cache\ArrayStore;
use Illuminate\Cache\FileStore as LaravelFileStore;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Orchestra\Testbench\Attributes\DefineEnvironment;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Extensions\FileStore;
use Tests\TestCase;

class FileStoreTest extends TestCase
{
private $dir;

public function setUp(): void
{
parent::setUp();

$this->dir = __DIR__.'/tmp-file-store';
}

public function tearDown(): void
{
(new Filesystem)->deleteDirectory($this->dir);
Carbon::setTestNow();

parent::tearDown();
}

#[Test]
#[DefineEnvironment('cache')]
public function it_overrides_file_driven_stores()
Expand Down Expand Up @@ -44,4 +65,86 @@ public function cache($app)
'driver' => 'array',
]);
}

public static function keyProvider()
{
return [
'stache key' => ['stache::items::entries::articles::'],
'other key' => ['other-'],
];
}

#[Test]
#[DataProvider('keyProvider')]
public function it_reads_values($prefix)
{
$store = new FileStore(new Filesystem, $this->dir);

$store->forever($prefix.'array', ['foo' => 'bar']);
$store->forever($prefix.'false', false);
$store->forever($prefix.'null', null);
$store->put($prefix.'expiring', 'baz', 60);

$this->assertEquals(['foo' => 'bar'], $store->get($prefix.'array'));
$this->assertFalse($store->get($prefix.'false'));
$this->assertNull($store->get($prefix.'null'));
$this->assertEquals('baz', $store->get($prefix.'expiring'));
$this->assertNull($store->get($prefix.'missing'));
$this->assertEquals(
[$prefix.'array' => ['foo' => 'bar'], $prefix.'missing' => null],
$store->many([$prefix.'array', $prefix.'missing'])
);
}

#[Test]
#[DataProvider('keyProvider')]
public function expired_values_are_forgotten($prefix)
{
$store = new FileStore(new Filesystem, $this->dir);

Carbon::setTestNow(Carbon::parse('2025-01-01 00:00:00'));
$store->put($prefix.'expiring', 'baz', 60);
$store->forever($prefix.'forever', 'qux');

Carbon::setTestNow(Carbon::parse('2025-01-01 00:01:00'));

$this->assertNull($store->get($prefix.'expiring'));
$this->assertFileDoesNotExist($store->path($prefix.'expiring'));
$this->assertEquals('qux', $store->get($prefix.'forever'));
}

#[Test]
#[DataProvider('corruptFileProvider')]
public function corrupt_files_are_forgotten($contents)
{
$store = new FileStore(new Filesystem, $this->dir);
$path = $store->path('stache::items::entries::articles::corrupt');
@mkdir(dirname($path), 0777, true);
file_put_contents($path, $contents);

$this->assertNull($store->get('stache::items::entries::articles::corrupt'));
$this->assertFileDoesNotExist($path);
}

public static function corruptFileProvider()
{
return [
'empty' => [''],
'only part of the expiration' => ['99999'],
'truncated value' => ['9999999999'.substr(serialize(['foo' => str_repeat('bar', 100)]), 0, 50)],
];
}

#[Test]
public function it_only_unserializes_allowed_classes_when_configured()
{
if (! method_exists(LaravelFileStore::class, 'unserialize')) {
$this->markTestSkipped('This version of Laravel does not support restricting unserializable classes.');
}

$store = new FileStore(new Filesystem, $this->dir, null, []);
$store->forever('stache::items::entries::articles::object', new \ArrayObject(['foo' => 'bar']));

$this->assertInstanceOf(\__PHP_Incomplete_Class::class, $store->get('stache::items::entries::articles::object'));
}
}
Loading