diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index a432150acbb..e6aa9b2b4f3 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -227,7 +227,7 @@ public function exists() return false; } - return $this->container()->files()->contains($path); + return $this->container()->contents()->hasFile($path); } public function getRawMeta() @@ -293,9 +293,11 @@ public function generateMeta() if ($this->exists()) { $attributes = Attributes::asset($this)->get(); + // The listing already holds the size and timestamp of every file, + // so only ask the disk for them when it somehow doesn't. $meta = array_merge($meta, [ - 'size' => $this->disk()->size($this->path()), - 'last_modified' => $this->disk()->lastModified($this->path()), + 'size' => $this->listingAttribute('size') ?? $this->disk()->size($this->path()), + 'last_modified' => $this->listingAttribute('timestamp') ?? $this->disk()->lastModified($this->path()), 'width' => Arr::get($attributes, 'width'), 'height' => Arr::get($attributes, 'height'), 'mime_type' => $this->disk()->mimeType($this->path()), @@ -315,7 +317,7 @@ public function metaPath() protected function metaExists() { - return $this->container()->metaFiles()->contains($this->metaPath()); + return $this->container()->contents()->hasMetaFile($this->metaPath()); } public function writeMeta($meta) @@ -606,7 +608,25 @@ public function mimeType() */ public function lastModified() { - return Carbon::createFromTimestamp($this->meta('last_modified') ?? 0, config('app.timezone')); + $timestamp = $this->listingAttribute('timestamp') ?? $this->meta('last_modified') ?? 0; + + return Carbon::createFromTimestamp($timestamp, config('app.timezone')); + } + + /** + * Get a value the container listing already knows about this file. + * + * The listing holds the size and timestamp of every file, so these can be + * answered without reading (or generating) the asset's meta file, which + * is a request per asset when the container is on a remote disk. + */ + private function listingAttribute($key) + { + if (! $path = $this->path()) { + return null; + } + + return $this->container()->contents()->file($path)[$key] ?? null; } /** @@ -925,7 +945,7 @@ public function ratio() */ public function size() { - return $this->meta('size'); + return $this->listingAttribute('size') ?? $this->meta('size'); } /** @@ -983,6 +1003,8 @@ public function reupload(ReplacementFile $file) ); } + $this->container()->contents()->add($this->path())->save(); + $this->clearCaches(); $this->writeMeta($this->generateMeta()); diff --git a/src/Assets/AssetContainerContents.php b/src/Assets/AssetContainerContents.php index 9c32f2defba..231dd5d9b19 100644 --- a/src/Assets/AssetContainerContents.php +++ b/src/Assets/AssetContainerContents.php @@ -176,6 +176,48 @@ public function directories() return $this->all()->where('type', 'dir'); } + /** + * Whether an asset file exists at the given path. + * + * This looks the path up directly rather than filtering the whole listing, + * so checking one asset doesn't cost a pass over every file in the container. + */ + public function hasFile($path): bool + { + return $this->isFile($path) && ! $this->isMetaPath($path) && ! $this->isIgnoredPath($path); + } + + /** + * Get the listing attributes (type, path, timestamp, size) of an asset file. + */ + public function file($path): ?array + { + return $this->hasFile($path) ? $this->all()->get($path) : null; + } + + /** + * Whether a meta file exists at the given path. + */ + public function hasMetaFile($path): bool + { + return $this->isFile($path) && $this->isMetaPath($path); + } + + private function isFile($path): bool + { + return ($this->all()->get($path)['type'] ?? null) === 'file'; + } + + private function isMetaPath($path): bool + { + return Str::startsWith($path, '.meta/') || Str::contains($path, '/.meta/'); + } + + private function isIgnoredPath($path): bool + { + return Str::endsWith($path, ['.DS_Store', '.gitkeep', '.gitignore']); + } + public function metaFilesIn($folder, $recursive) { if (isset($this->metaFiles[$key = $folder.($recursive ? '-recursive' : '')])) { diff --git a/tests/Assets/AssetTest.php b/tests/Assets/AssetTest.php index c5fde7bc263..fd276ecf86c 100644 --- a/tests/Assets/AssetTest.php +++ b/tests/Assets/AssetTest.php @@ -540,6 +540,96 @@ public function it_checks_if_it_exists() $this->assertFalse($noPath->exists()); } + #[Test] + public function checking_existence_does_not_filter_the_whole_container_listing() + { + $disk = Storage::fake('test'); + $disk->put('yes.txt', ''); + $disk->put('.meta/yes.txt.yaml', ''); + $disk->put('sub/.meta/nested.txt.yaml', ''); + $disk->put('sub/.gitkeep', ''); + + $contents = new class extends \Statamic\Assets\AssetContainerContents + { + public $filtered = 0; + + public function filteredFilesIn($folder, $recursive) + { + $this->filtered++; + + return parent::filteredFilesIn($folder, $recursive); + } + + public function metaFilesIn($folder, $recursive) + { + $this->filtered++; + + return parent::metaFilesIn($folder, $recursive); + } + }; + + $this->app->bind(\Statamic\Assets\AssetContainerContents::class, fn () => $contents); + + $asset = fn ($path) => (new Asset)->container($this->container)->path($path); + + $this->assertTrue($asset('yes.txt')->exists()); + $this->assertFalse($asset('no.txt')->exists()); + $this->assertFalse($asset('sub')->exists()); + $this->assertFalse($asset('.meta/yes.txt.yaml')->exists()); + $this->assertFalse($asset('sub/.meta/nested.txt.yaml')->exists()); + $this->assertFalse($asset('sub/.gitkeep')->exists()); + + $this->assertTrue($contents->hasMetaFile('.meta/yes.txt.yaml')); + $this->assertTrue($contents->hasMetaFile('sub/.meta/nested.txt.yaml')); + $this->assertFalse($contents->hasMetaFile('yes.txt')); + $this->assertFalse($contents->hasMetaFile('.meta/no.txt.yaml')); + + $this->assertSame(0, $contents->filtered); + } + + #[Test] + public function it_gets_size_and_last_modified_from_the_container_listing_without_touching_meta() + { + Carbon::setTestNow('2017-01-02 14:35:00'); + $disk = Storage::fake('test'); + $disk->put('foo/test.txt', 'four'); + touch($disk->path('foo/test.txt'), Carbon::now()->timestamp); + + $asset = (new Asset)->container($this->container)->path('foo/test.txt'); + + $this->assertSame(4, $asset->size()); + $this->assertEquals(Carbon::now()->timestamp, $asset->lastModified()->timestamp); + $this->assertSame(4, $asset->getQueryableValue('size')); + + // Neither needed the meta, so it was not read, generated or written. + $disk->assertMissing('foo/.meta/test.txt.yaml'); + $this->assertNull($asset->getRawMeta()); + } + + #[Test] + public function generating_meta_uses_the_size_and_timestamp_the_listing_already_has() + { + Storage::fake('test'); + $file = UploadedFile::fake()->image('image.jpg', 30, 60); + Storage::disk('test')->putFileAs('foo', $file, 'image.jpg'); + touch(Storage::disk('test')->path('foo/image.jpg'), $listed = Carbon::parse('2021-02-22 09:41:42')->timestamp); + + $container = Facades\AssetContainer::make('test')->disk('test'); + $asset = (new Asset)->container($container)->path('foo/image.jpg'); + $container->contents()->all(); + + // If the disk were asked again it would report this newer time. + touch(Storage::disk('test')->path('foo/image.jpg'), $listed + 100); + clearstatcache(); + + $meta = $asset->generateMeta(); + + $this->assertSame($listed, $meta['last_modified']); + $this->assertSame(Storage::disk('test')->size('foo/image.jpg'), $meta['size']); + $this->assertSame(30, $meta['width']); + $this->assertSame('image/jpeg', $meta['mime_type']); + } + #[Test] public function it_gets_the_filename() { @@ -911,7 +1001,7 @@ public function it_generates_meta_on_demand_if_a_required_value_is_missing() Storage::disk('test')->put('foo/.meta/image.jpg.yaml', YAML::dump($incompleteMeta)); - $asset->size(); + $asset->mimeType(); $this->assertEquals($completeMeta, YAML::parse(Storage::disk('test')->get('foo/.meta/image.jpg.yaml'))); }