diff --git a/src/Fieldtypes/Video.php b/src/Fieldtypes/Video.php index 5e12dbf48bf..aea2ee6fb07 100644 --- a/src/Fieldtypes/Video.php +++ b/src/Fieldtypes/Video.php @@ -3,6 +3,7 @@ namespace Statamic\Fieldtypes; use Statamic\Fields\Fieldtype; +use Statamic\Fieldtypes\Video\Embed; use function Statamic\trans as __; @@ -10,6 +11,15 @@ class Video extends Fieldtype { protected $categories = ['media']; + public function augment($value) + { + if (is_null($value)) { + return null; + } + + return Embed::fromValue($value); + } + protected function configFieldItems(): array { return [ diff --git a/src/Fieldtypes/Video/Embed.php b/src/Fieldtypes/Video/Embed.php new file mode 100644 index 00000000000..1d06b419beb --- /dev/null +++ b/src/Fieldtypes/Video/Embed.php @@ -0,0 +1,222 @@ +provider, [self::FILE, self::UNSUPPORTED]); + } + + public function isSupported(): bool + { + return $this->provider !== self::UNSUPPORTED; + } + + public function toArray(): array + { + return [ + 'embed_url' => $this->embedUrl, + 'id' => $this->id, + 'provider' => $this->provider, + 'url' => $this->url, + ]; + } + + public function toBool(): bool + { + return (bool) $this->url; + } + + public function __toString(): string + { + return (string) $this->url; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return (string) $this; + } + + #[\ReturnTypeWillChange] + public function offsetExists(mixed $offset) + { + return array_key_exists($offset, $this->toArray()); + } + + #[\ReturnTypeWillChange] + public function offsetGet(mixed $offset) + { + return $this->toArray()[$offset] ?? null; + } + + #[\ReturnTypeWillChange] + public function offsetSet(mixed $offset, mixed $value) + { + } + + #[\ReturnTypeWillChange] + public function offsetUnset(mixed $offset) + { + } + + /** + * Turn a link that's direct to a video's page into its embeddable equivalent. + */ + public static function embedUrl(?string $url): ?string + { + if (blank($url)) { + return $url; + } + + if (Str::contains($url, self::VIMEO)) { + return static::vimeoEmbedUrl($url); + } + + if (Str::contains($url, 'youtu.be')) { + $url = str_replace('youtu.be', 'www.youtube.com/embed', $url); + + // Check for start at point and replace it with correct parameter. + if (Str::contains($url, '?t=')) { + $url = str_replace('?t=', '?start=', $url); + } + } + + if (Str::contains($url, 'youtube.com/watch?v=')) { + $url = str_replace('watch?v=', 'embed/', $url); + + if (Str::contains($url, '&t=')) { + $url = str_replace('&t=', '?start=', $url); + } + } + + if (Str::contains($url, 'youtube.com/shorts/')) { + $url = str_replace('shorts/', 'embed/', $url); + } + + if (Str::contains($url, 'youtube.com')) { + $url = str_replace('youtube.com', 'youtube-nocookie.com', $url); + } + + // This avoids SSL issues when using the non-www version + if (Str::contains($url, '//youtube-nocookie.com')) { + $url = str_replace('//youtube-nocookie.com', '//www.youtube-nocookie.com', $url); + } + + if (Str::contains($url, '&') && ! Str::contains($url, '?')) { + $url = Str::replaceFirst('&', '?', $url); + } + + return $url; + } + + public static function isEmbeddableUrl(?string $url): bool + { + return filled($url) && Str::contains($url, ['youtu.be', 'youtube', self::VIMEO]); + } + + protected static function isVideoFile(string $url): bool + { + if (blank($path = parse_url($url, PHP_URL_PATH))) { + return false; + } + + return in_array(strtolower(pathinfo($path, PATHINFO_EXTENSION)), FileTypes::video()); + } + + protected static function oembedProvider(string $url): ?string + { + if (Str::contains($url, self::VIMEO)) { + return self::VIMEO; + } + + if (Str::contains($url, ['youtu.be', 'youtube'])) { + return self::YOUTUBE; + } + + return null; + } + + // Unlisted vimeo urls are in the form vimeo.com/id/hash, but embeds pass the hash as a get param. + protected static function vimeoEmbedUrl(string $url): string + { + $url = str_replace('/vimeo.com', '/player.vimeo.com/video', $url); + $hash = ''; + + if (! Str::contains($url, 'progressive_redirect') && Str::substrCount($url, '/') > 4) { + $hash = Str::afterLast($url, '/'); + $url = Str::beforeLast($url, '/'); + + if (Str::contains($hash, '?')) { + $url .= '?'.Str::after($hash, '?'); + $hash = Str::before($hash, '?'); + } + } + + $paramsToAdd = '?dnt=1'; + + if ($hash) { + $paramsToAdd .= '&h='.$hash; + } + + return Str::contains($url, '?') + ? str_replace('?', $paramsToAdd.'&', $url) + : $url.$paramsToAdd; + } +} diff --git a/src/Modifiers/CoreModifiers.php b/src/Modifiers/CoreModifiers.php index 42e3d800704..1f04fcf8df8 100644 --- a/src/Modifiers/CoreModifiers.php +++ b/src/Modifiers/CoreModifiers.php @@ -28,6 +28,7 @@ use Statamic\Fieldtypes\Bard; use Statamic\Fieldtypes\Bard\Augmentor; use Statamic\Fieldtypes\Link\ArrayableLink; +use Statamic\Fieldtypes\Video\Embed; use Statamic\Statamic; use Statamic\Support\Arr; use Statamic\Support\Dumper; @@ -35,6 +36,7 @@ use Statamic\Support\Str; use Statamic\Support\Traits\ChecksDumpability; use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; +use Stringable; use Stringy\StaticStringy as Stringy; use function Statamic\trans; @@ -1560,7 +1562,9 @@ public function length($value) return $value->count(); } - if ($value instanceof Arrayable) { + // Value objects like ArrayableString are both Arrayable and Stringable. + // They stand in for a string, so measure the string, not the array. + if ($value instanceof Arrayable && ! $value instanceof Stringable) { $value = $value->toArray(); } @@ -3199,60 +3203,11 @@ public function yearsAgo($value, $params) */ public function embedUrl($url) { - if (Str::contains($url, 'vimeo')) { - $url = str_replace('/vimeo.com', '/player.vimeo.com/video', $url); - - [$url, $hash] = $this->handleUnlistedVimeoUrls($url); - - $paramsToAdd = '?dnt=1'; - if ($hash) { - $paramsToAdd .= '&h='.$hash; - } - - if (Str::contains($url, '?')) { - $url = str_replace('?', $paramsToAdd.'&', $url); - } else { - $url .= $paramsToAdd; - } - - return $url; - } - - if (Str::contains($url, 'youtu.be')) { - $url = str_replace('youtu.be', 'www.youtube.com/embed', $url); - - // Check for start at point and replace it with correct parameter. - if (Str::contains($url, '?t=')) { - $url = str_replace('?t=', '?start=', $url); - } - } - - if (Str::contains($url, 'youtube.com/watch?v=')) { - $url = str_replace('watch?v=', 'embed/', $url); - - if (Str::contains($url, '&t=')) { - $url = str_replace('&t=', '?start=', $url); - } - } - - if (Str::contains($url, 'youtube.com/shorts/')) { - $url = str_replace('shorts/', 'embed/', $url); + if ($url instanceof Embed) { + return $url->embedUrl ?? $url->url; } - if (Str::contains($url, 'youtube.com')) { - $url = str_replace('youtube.com', 'youtube-nocookie.com', $url); - } - - // This avoids SSL issues when using the non-www version - if (Str::contains($url, '//youtube-nocookie.com')) { - $url = str_replace('//youtube-nocookie.com', '//www.youtube-nocookie.com', $url); - } - - if (Str::contains($url, '&') && ! Str::contains($url, '?')) { - $url = Str::replaceFirst('&', '?', $url); - } - - return $url; + return Embed::embedUrl($url); } /** @@ -3264,6 +3219,14 @@ public function embedUrl($url) */ public function trackableEmbedUrl($url) { + if ($url instanceof Embed) { + $url = $url->url; + } + + if (blank($url)) { + return $url; + } + if (Str::contains($url, 'vimeo')) { return str_replace('/vimeo.com', '/player.vimeo.com/video', $url); } @@ -3296,7 +3259,11 @@ public function trackableEmbedUrl($url) */ public function isEmbeddable($url) { - return Str::contains($url, ['youtu.be', 'youtube', 'vimeo']); + if ($url instanceof Embed) { + return $url->isEmbeddable(); + } + + return Embed::isEmbeddableUrl($url); } /** @@ -3381,24 +3348,6 @@ private function getFromContext($context, $params, $key = 0) Arr::get($context, $params[$key], $params[$key]); } - // unlisted vimeo urls are in the form vimeo.com/id/hash, but embeds pass the hash as a get param - private function handleUnlistedVimeoUrls($url) - { - $hash = ''; - - if (! Str::contains($url, 'progressive_redirect') && Str::substrCount($url, '/') > 4) { - $hash = Str::afterLast($url, '/'); - $url = Str::beforeLast($url, '/'); - - if (Str::contains($hash, '?')) { - $url .= '?'.Str::after($hash, '?'); - $hash = Str::before($hash, '?'); - } - } - - return [$url, $hash]; - } - private function dumpingAllowed(array $params): bool { return $this->traitDumpingAllowed() || (Arr::get($params, 0) === 'force'); diff --git a/tests/Fieldtypes/Video/EmbedTest.php b/tests/Fieldtypes/Video/EmbedTest.php new file mode 100644 index 00000000000..f386e8b5217 --- /dev/null +++ b/tests/Fieldtypes/Video/EmbedTest.php @@ -0,0 +1,96 @@ +assertSame($provider, $video->provider); + $this->assertSame($id, $video->id); + $this->assertSame($embedUrl, $video->embedUrl); + } + + public static function valuesProvider() + { + return [ + 'youtube' => ['https://www.youtube.com/watch?v=FK3dav4bA4s', 'youtube', null, 'https://www.youtube-nocookie.com/embed/FK3dav4bA4s'], + 'youtube shorts' => ['https://www.youtube.com/shorts/FK3dav4bA4s', 'youtube', null, 'https://www.youtube-nocookie.com/embed/FK3dav4bA4s'], + 'youtu.be' => ['https://youtu.be/FK3dav4bA4s', 'youtube', null, 'https://www.youtube-nocookie.com/embed/FK3dav4bA4s'], + 'vimeo' => ['https://vimeo.com/22439234', 'vimeo', null, 'https://player.vimeo.com/video/22439234?dnt=1'], + 'cloudflare' => ['cloudflare:1234', 'cloudflare', '1234', 'https://iframe.cloudflarestream.com/1234'], + 'cloudflare without an id' => ['cloudflare:', 'unsupported', null, null], + 'cloudflare with a malformed id' => ['cloudflare:1234">', 'unsupported', null, null], + 'cloudflare with a path traversal id' => ['cloudflare:../../evil', 'unsupported', null, null], + 'mp4 file' => ['https://example.com/clip.mp4', 'file', null, 'https://example.com/clip.mp4'], + 'uppercase file extension' => ['https://example.com/clip.MOV', 'file', null, 'https://example.com/clip.MOV'], + 'file with a query string' => ['https://example.com/clip.webm?t=1', 'file', null, 'https://example.com/clip.webm?t=1'], + 'unsupported' => ['https://example.com/nope', 'unsupported', null, null], + 'empty' => ['', 'unsupported', null, null], + 'null' => [null, 'unsupported', null, null], + ]; + } + + #[Test] + public function it_casts_to_the_original_value() + { + $this->assertSame('https://vimeo.com/22439234', (string) Embed::fromValue('https://vimeo.com/22439234')); + $this->assertSame('cloudflare:1234', (string) Embed::fromValue('cloudflare:1234')); + $this->assertSame('', (string) Embed::fromValue(null)); + } + + #[Test] + public function it_is_truthy_whenever_it_holds_a_value() + { + $this->assertTrue(Embed::fromValue('https://vimeo.com/22439234')->toBool()); + $this->assertTrue(Embed::fromValue('https://example.com/nope')->toBool()); + $this->assertFalse(Embed::fromValue('')->toBool()); + } + + #[Test] + public function it_knows_whether_it_is_supported_and_embeddable() + { + $this->assertTrue(Embed::fromValue('https://vimeo.com/22439234')->isEmbeddable()); + $this->assertTrue(Embed::fromValue('https://vimeo.com/22439234')->isSupported()); + + // A file is something we can play, but not something we can put in an iframe. + $this->assertFalse(Embed::fromValue('https://example.com/clip.mp4')->isEmbeddable()); + $this->assertTrue(Embed::fromValue('https://example.com/clip.mp4')->isSupported()); + + $this->assertFalse(Embed::fromValue('https://example.com/nope')->isEmbeddable()); + $this->assertFalse(Embed::fromValue('https://example.com/nope')->isSupported()); + } + + #[Test] + public function it_is_arrayable_and_accessible_as_an_array() + { + $video = Embed::fromValue('cloudflare:1234'); + + $this->assertSame([ + 'embed_url' => 'https://iframe.cloudflarestream.com/1234', + 'id' => '1234', + 'provider' => 'cloudflare', + 'url' => 'cloudflare:1234', + ], $video->toArray()); + + $this->assertSame('https://iframe.cloudflarestream.com/1234', $video['embed_url']); + $this->assertTrue(isset($video['provider'])); + $this->assertNull($video['nope']); + } + + #[Test] + public function it_serializes_to_json_as_the_original_value() + { + $this->assertSame('"cloudflare:1234"', json_encode(Embed::fromValue('cloudflare:1234'))); + $this->assertSame('"https:\\/\\/vimeo.com\\/1"', json_encode(Embed::fromValue('https://vimeo.com/1'))); + } +} diff --git a/tests/Fieldtypes/Video/ParityTest.php b/tests/Fieldtypes/Video/ParityTest.php new file mode 100644 index 00000000000..e0de27ac11a --- /dev/null +++ b/tests/Fieldtypes/Video/ParityTest.php @@ -0,0 +1,103 @@ +` is a new format + * with no prior behaviour to preserve. VideoTest covers it instead. + */ +class ParityTest extends TestCase +{ + public static function valuesProvider() + { + return [ + 'null' => [null], + 'empty' => [''], + 'unsupported' => ['https://example.com/nope'], + 'youtube' => ['https://www.youtube.com/watch?v=FK3dav4bA4s'], + 'youtube shorts' => ['https://www.youtube.com/shorts/FK3dav4bA4s'], + 'youtu.be' => ['https://youtu.be/FK3dav4bA4s'], + 'vimeo' => ['https://vimeo.com/22439234'], + 'unlisted vimeo' => ['https://vimeo.com/22439234/abcdef'], + 'vimeo progressive file' => ['https://player.vimeo.com/progressive_redirect/playback/123/rendition/1080p/file.mp4?loc=external'], + 'video file' => ['https://example.com/clip.mp4'], + ]; + } + + #[Test] + #[DataProvider('valuesProvider')] + public function the_embed_url_modifier_matches_the_raw_value($value) + { + $this->assertSame( + Modify::value($value)->embedUrl()->fetch(), + Modify::value($this->augment($value))->embedUrl()->fetch(), + ); + } + + #[Test] + #[DataProvider('valuesProvider')] + public function the_trackable_embed_url_modifier_matches_the_raw_value($value) + { + $this->assertSame( + Modify::value($value)->trackableEmbedUrl()->fetch(), + Modify::value($this->augment($value))->trackableEmbedUrl()->fetch(), + ); + } + + #[Test] + #[DataProvider('valuesProvider')] + public function the_is_embeddable_modifier_matches_the_raw_value($value) + { + $this->assertSame( + Modify::value($value)->isEmbeddable()->fetch(), + Modify::value($this->augment($value))->isEmbeddable()->fetch(), + ); + } + + #[Test] + #[DataProvider('valuesProvider')] + public function it_casts_to_the_original_value($value) + { + $this->assertSame((string) $value, (string) $this->augment($value)); + } + + #[Test] + #[DataProvider('valuesProvider')] + public function it_is_as_truthy_as_the_original_value($value) + { + // A null value isn't augmented at all, so there's no object to ask. + if (is_null($augmented = $this->augment($value))) { + $this->assertNull($value); + + return; + } + + $this->assertSame((bool) $value, $augmented->toBool()); + } + + #[Test] + #[DataProvider('valuesProvider')] + public function it_serializes_to_the_original_value($value) + { + $field = (new Video)->setField(new Field('test', ['type' => 'video'])); + + $this->assertSame(json_encode($value), json_encode(new Value($value, 'test', $field))); + } + + private function augment($value) + { + return (new Video)->setField(new Field('test', ['type' => 'video']))->augment($value); + } +} diff --git a/tests/Fieldtypes/VideoTest.php b/tests/Fieldtypes/VideoTest.php new file mode 100644 index 00000000000..ee6646b298e --- /dev/null +++ b/tests/Fieldtypes/VideoTest.php @@ -0,0 +1,55 @@ +assertNull($this->fieldtype()->augment(null)); + } + + #[Test] + #[DataProvider('augmentProvider')] + public function it_augments_to_a_video($value, $provider, $id, $embedUrl) + { + $video = $this->fieldtype()->augment($value); + + $this->assertInstanceOf(Embed::class, $video); + $this->assertSame($provider, $video->provider); + $this->assertSame($id, $video->id); + $this->assertSame($embedUrl, $video->embedUrl); + } + + public static function augmentProvider() + { + return [ + 'url' => ['https://vimeo.com/22439234', 'vimeo', null, 'https://player.vimeo.com/video/22439234?dnt=1'], + 'cloudflare' => ['cloudflare:1234', 'cloudflare', '1234', 'https://iframe.cloudflarestream.com/1234'], + 'file' => ['https://example.com/clip.mp4', 'file', null, 'https://example.com/clip.mp4'], + 'unsupported' => ['https://example.com/nope', 'unsupported', null, null], + ]; + } + + #[Test] + public function the_augmented_value_casts_to_the_original_value_for_backwards_compatibility() + { + $this->assertSame( + 'https://vimeo.com/22439234', + (string) $this->fieldtype()->augment('https://vimeo.com/22439234'), + ); + } + + private function fieldtype() + { + return (new Video)->setField(new Field('test', ['type' => 'video'])); + } +} diff --git a/tests/Modifiers/EmbedUrlTest.php b/tests/Modifiers/EmbedUrlTest.php index 6061c170f3f..645add32196 100644 --- a/tests/Modifiers/EmbedUrlTest.php +++ b/tests/Modifiers/EmbedUrlTest.php @@ -3,6 +3,7 @@ namespace Tests\Modifiers; use PHPUnit\Framework\Attributes\Test; +use Statamic\Fieldtypes\Video\Embed; use Statamic\Modifiers\Modify; use Tests\TestCase; @@ -113,6 +114,20 @@ public function it_ensures_url_with_query_parameters_are_valid() ); } + #[Test] + public function it_gets_the_embed_url_from_an_augmented_video_value() + { + $this->assertEquals( + 'https://iframe.cloudflarestream.com/1234', + $this->embed(Embed::fromValue('cloudflare:1234')), + ); + + $this->assertEquals( + 'https://player.vimeo.com/video/22439234?dnt=1', + $this->embed(Embed::fromValue('https://vimeo.com/22439234')), + ); + } + public function embed($url) { return Modify::value($url)->embedUrl()->fetch(); diff --git a/tests/Modifiers/LengthTest.php b/tests/Modifiers/LengthTest.php index 826d7168dc6..117c39c43fe 100644 --- a/tests/Modifiers/LengthTest.php +++ b/tests/Modifiers/LengthTest.php @@ -6,6 +6,7 @@ use Mockery; use PHPUnit\Framework\Attributes\Test; use Statamic\Contracts\Query\Builder; +use Statamic\Fields\ArrayableString; use Statamic\Modifiers\Modify; use Tests\TestCase; @@ -47,12 +48,26 @@ public function it_returns_the_number_of_items_in_a_query() #[Test] public function it_returns_the_number_of_items_in_an_arrayable() { - $arrayable = Mockery::mock(Arrayable::class)->shouldReceive('toArray')->andReturn(['one', 'two'])->getMock(); + $arrayable = new class implements Arrayable + { + public function toArray() + { + return ['one', 'two']; + } + }; $modified = $this->modify($arrayable); $this->assertSame(2, $modified); } + #[Test] + public function it_returns_the_number_of_chars_in_a_stringable_arrayable() + { + // Value objects like ArrayableString stand in for a string, so the + // string is what should get measured, not their array form. + $this->assertSame(19, $this->modify(new ArrayableString('https://vimeo.com/1'))); + } + #[Test] public function it_returns_the_numbers_of_chars_in_string(): void {