Skip to content
Merged
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
1 change: 1 addition & 0 deletions lang/en/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -305,5 +305,6 @@
'user_wizard_invitation_subject' => 'Activate your new Statamic account on :site',
'user_wizard_roles_groups_intro' => 'Users can be assigned to roles that customize their permissions, access, and abilities throughout the Control Panel.',
'user_wizard_super_admin_instructions' => 'Super admins have complete control and access to everything in the control panel. Grant this role wisely.',
'video_fieldtype_cloudflare_manifest' => 'Cloudflare Stream manifest URLs can\'t be embedded. Use the URL from the "Video Link" button in Cloudflare instead.',
'width_x_height' => ':width × :height',
];
26 changes: 25 additions & 1 deletion resources/js/components/fieldtypes/VideoFieldtype.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
/>
</ui-input-group>
<ui-description v-if="isInvalid" class="text-red-600">{{ __('statamic::validation.url') }}</ui-description>
<ui-description v-if="isCloudflareManifest">{{ __('statamic::messages.video_fieldtype_cloudflare_manifest') }}</ui-description>
<iframe
v-if="shouldShowPreview"
ref="iframe"
Expand All @@ -29,6 +30,9 @@
<script>
import Fieldtype from './Fieldtype.vue';

const CLOUDFLARE_URL_PATTERN = /^https?:\/\/(customer-[a-z0-9]+\.cloudflarestream\.com)\/([a-z0-9]+)(\/|\/iframe\/?|\/watch\/?)?(?:\?([^\s#]*))?(?:#\S*)?$/i;
const CLOUDFLARE_MANIFEST_PATTERN = /^https?:\/\/customer-[a-z0-9]+\.cloudflarestream\.com\/[a-z0-9]+\/manifest\//i;

export default {
mixins: [Fieldtype],

Expand All @@ -44,7 +48,27 @@ export default {
return !this.isInvalid && (this.isEmbeddable || this.isVideo);
},

cloudflare() {
const match = CLOUDFLARE_URL_PATTERN.exec(this.value || '');

if (!match) return null;

const isWatch = (match[3] || '').toLowerCase().startsWith('/watch');

return { host: match[1], id: match[2], query: isWatch ? '' : new URLSearchParams(match[4] || '').toString() };
},

isCloudflareManifest() {
return CLOUDFLARE_MANIFEST_PATTERN.test(this.value || '');
},

embedUrl() {
if (this.cloudflare) {
const { host, id, query } = this.cloudflare;

return `https://${host}/${id}/iframe${query ? `?${query}` : ''}`;
}

let embed_url = this.value || '';

if (embed_url.includes('youtube')) {
Expand Down Expand Up @@ -77,7 +101,7 @@ export default {
const url = this.value || '';
const isYoutube = url.includes('youtube') || url.includes('youtu.be');
const isVimeo = url.includes('vimeo');
return isYoutube || isVimeo;
return isYoutube || isVimeo || !!this.cloudflare;
},

isInvalid() {
Expand Down
59 changes: 59 additions & 0 deletions resources/js/tests/components/fieldtypes/VideoFieldtype.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@ import { publishContextKey } from '@/components/ui';

window.__ = (key) => key;

let intersect;

beforeEach(() => {
vi.useFakeTimers();

window.IntersectionObserver = class {
constructor(callback) {
intersect = () => callback([{ isIntersecting: true, intersectionRatio: 1 }]);
}
observe() {}
disconnect() {}
};
Expand Down Expand Up @@ -45,6 +50,60 @@ const mountVideoField = (props = {}) => {
});
};

const cloudflare = 'https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b';

const preview = async (value) => {
const wrapper = mountVideoField({ value });
intersect();
await wrapper.vm.$nextTick();

return wrapper.find('iframe');
};

test('it does not preview a cloudflare manifest url', async () => {
expect((await preview(`${cloudflare}/manifest/video.m3u8`)).exists()).toBe(false);
});

test.each(['manifest/video.m3u8', 'manifest/video.mpd'])('it hints to use the video link for a cloudflare %s url', (path) => {
const wrapper = mountVideoField({ value: `${cloudflare}/${path}` });

expect(wrapper.find('p').text()).toBe('statamic::messages.video_fieldtype_cloudflare_manifest');
});

test.each(['watch', 'iframe'])('it does not hint for a cloudflare %s url', (path) => {
const wrapper = mountVideoField({ value: `${cloudflare}/${path}` });

expect(wrapper.find('p').exists()).toBe(false);
});

test('it previews a cloudflare iframe url', async () => {
expect((await preview(`${cloudflare}/iframe`)).attributes('src')).toBe(`${cloudflare}/iframe`);
});

test('it keeps the player options of a cloudflare iframe url', async () => {
expect((await preview(`${cloudflare}/iframe?autoplay=true&muted=true`)).attributes('src')).toBe(`${cloudflare}/iframe?autoplay=true&muted=true`);
});

test('it drops the query string of a cloudflare watch url', async () => {
expect((await preview(`${cloudflare}/watch?autoplay=true`)).attributes('src')).toBe(`${cloudflare}/iframe`);
});

test('it previews a cloudflare watch url as its iframe', async () => {
expect((await preview(`${cloudflare}/watch`)).attributes('src')).toBe(`${cloudflare}/iframe`);
});

test('it previews a cloudflare download as a video file', async () => {
expect((await preview(`${cloudflare}/downloads/default.mp4`)).attributes('src')).toBe(`${cloudflare}/downloads/default.mp4`);
});

test('it does not preview a cloudflare url with a malformed id', async () => {
expect((await preview('https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/abc-123/iframe')).exists()).toBe(false);
});

test('it does not preview a cloudflare url with html in it', async () => {
expect((await preview('https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/1234"><script>alert(1)</script>')).exists()).toBe(false);
});

test('it debounces updates while typing', async () => {
const wrapper = mountVideoField({ value: null });
const input = wrapper.find('input');
Expand Down
37 changes: 35 additions & 2 deletions src/Fieldtypes/Video/Embed.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@

class Embed extends ArrayableString
{
const CLOUDFLARE = 'cloudflare';
const FILE = 'file';
const UNSUPPORTED = 'unsupported';
const VIMEO = 'vimeo';
const YOUTUBE = 'youtube';

private const CLOUDFLARE_URL_PATTERN = '#^https?://(customer-[a-z0-9]+\.cloudflarestream\.com)/([a-z0-9]+)(/|/iframe/?|/watch/?)?(?:\?([^\s\#]*))?(?:\#\S*)?\z#i';
private const VIMEO_PRIVACY_HASH_PATTERN = '/^[a-zA-Z0-9]+$/';
private const YOUTUBE_ID_PATTERN = '/^[a-zA-Z0-9_-]+$/';

Expand All @@ -38,7 +40,7 @@ public function provider(): string
public function embedUrl(): ?string
{
return match ($this->provider) {
self::VIMEO, self::YOUTUBE => self::embedUrlFor($this->value),
self::CLOUDFLARE, self::VIMEO, self::YOUTUBE => self::embedUrlFor($this->value),
self::FILE => $this->value,
default => null,
};
Expand All @@ -55,6 +57,7 @@ public function trackableEmbedUrl(): ?string
public function id(): ?string
{
return match ($this->provider) {
self::CLOUDFLARE => self::cloudflareParts($this->value)[1],
self::VIMEO => self::vimeoIdAndPrivacyHash($this->value)[0],
self::YOUTUBE => self::youtubeId($this->value),
default => null,
Expand Down Expand Up @@ -121,6 +124,10 @@ public static function embedUrlFor(?string $url): ?string
return $url;
}

if ($parts = self::cloudflareParts($url)) {
return self::cloudflareEmbedUrl(...$parts);
}

if (Str::contains($url, self::VIMEO)) {
return self::vimeoEmbedUrl($url);
}
Expand Down Expand Up @@ -171,6 +178,10 @@ public static function trackableEmbedUrlFor(?string $url): ?string
return $url;
}

if ($parts = self::cloudflareParts($url)) {
return self::cloudflareEmbedUrl(...$parts);
}

if (Str::contains($url, self::VIMEO)) {
return str_replace('/vimeo.com', '/player.vimeo.com/video', $url);
}
Expand All @@ -197,7 +208,28 @@ public static function trackableEmbedUrlFor(?string $url): ?string

public static function isEmbeddableUrl(?string $url): bool
{
return filled($url) && Str::contains($url, ['youtu.be', 'youtube', self::VIMEO]);
return filled($url) && (Str::contains($url, ['youtu.be', 'youtube', self::VIMEO]) || self::cloudflareParts($url) !== null);
}

private static function cloudflareEmbedUrl(string $host, string $id, string $query): string
{
parse_str($query, $params);

$query = http_build_query($params, '', '&', PHP_QUERY_RFC3986);

return "https://{$host}/{$id}/iframe".($query ? "?{$query}" : '');
}

private static function cloudflareParts(?string $url): ?array
{
if (! preg_match(self::CLOUDFLARE_URL_PATTERN, $url ?? '', $matches)) {
return null;
}

// Player options only apply to the iframe, so a watch page's query string is dropped.
$query = Str::startsWith(strtolower($matches[3] ?? ''), '/watch') ? '' : ($matches[4] ?? '');

return [$matches[1], $matches[2], $query];
}

private static function isVideoFile(string $url): bool
Expand All @@ -213,6 +245,7 @@ private static function providerFor(?string $url): string
{
return match (true) {
blank($url) => self::UNSUPPORTED,
self::cloudflareParts($url) !== null => self::CLOUDFLARE,
Str::contains($url, self::VIMEO) => self::VIMEO,
Str::contains($url, ['youtu.be', 'youtube']) => self::YOUTUBE,
self::isVideoFile($url) => self::FILE,
Expand Down
19 changes: 19 additions & 0 deletions tests/Fieldtypes/Video/EmbedTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ public static function valuesProvider()
'youtube shorts' => ['https://www.youtube.com/shorts/FK3dav4bA4s', 'youtube', 'https://www.youtube-nocookie.com/embed/FK3dav4bA4s'],
'youtu.be' => ['https://youtu.be/FK3dav4bA4s', 'youtube', 'https://www.youtube-nocookie.com/embed/FK3dav4bA4s'],
'vimeo' => ['https://vimeo.com/22439234', 'vimeo', 'https://player.vimeo.com/video/22439234?dnt=1'],
'cloudflare iframe' => ['https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/iframe', 'cloudflare', 'https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/iframe'],
'cloudflare watch' => ['https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/watch', 'cloudflare', 'https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/iframe'],
'cloudflare with an uppercase host' => ['https://CUSTOMER-WVE7OZE7JJXDB0J6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/iframe', 'cloudflare', 'https://CUSTOMER-WVE7OZE7JJXDB0J6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/iframe'],
'cloudflare with a query string' => ['https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b?autoplay=true', 'cloudflare', 'https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/iframe?autoplay=true'],
'cloudflare iframe with player options' => ['https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/iframe?autoplay=true&muted=true&poster=https%3A%2F%2Fexample.com%2Fposter.jpg', 'cloudflare', 'https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/iframe?autoplay=true&muted=true&poster=https%3A%2F%2Fexample.com%2Fposter.jpg'],
'cloudflare iframe with a fragment' => ['https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/iframe?autoplay=true#t=1', 'cloudflare', 'https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/iframe?autoplay=true'],
'cloudflare iframe with html in the query string' => ['https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/iframe?poster="><script>alert(1)</script>', 'cloudflare', 'https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/iframe?poster=%22%3E%3Cscript%3Ealert%281%29%3C%2Fscript%3E'],
'cloudflare watch with a query string' => ['https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/watch?autoplay=true', 'cloudflare', 'https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/iframe'],
'cloudflare without an id' => ['https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/', 'unsupported', null],
'cloudflare with a malformed id' => ['https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/1234"></iframe><script>alert(1)</script>', 'unsupported', null],
'cloudflare with a path traversal id' => ['https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/../../evil', 'unsupported', null],
'cloudflare with a trailing newline' => ["https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b\n", 'unsupported', null],
'cloudflare without a customer subdomain' => ['https://iframe.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b', 'unsupported', null],
'cloudflare download' => ['https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/downloads/default.mp4', 'file', 'https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/downloads/default.mp4'],
'cloudflare thumbnail' => ['https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/thumbnails/thumbnail.jpg', 'unsupported', null],
'cloudflare hls manifest' => ['https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/manifest/video.m3u8', 'unsupported', null],
'cloudflare dash manifest' => ['https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/manifest/video.mpd', 'unsupported', null],
'mp4 file' => ['https://example.com/clip.mp4', 'file', 'https://example.com/clip.mp4'],
'uppercase file extension' => ['https://example.com/clip.MOV', 'file', 'https://example.com/clip.MOV'],
'file with a query string' => ['https://example.com/clip.webm?t=1', 'file', 'https://example.com/clip.webm?t=1'],
Expand Down Expand Up @@ -78,6 +95,7 @@ public static function idsProvider()
'vimeo progressive file with the api format' => ['https://player.vimeo.com/progressive_redirect/playback/286898202/container/d8f1d190-1e26-407e-90a4-45991fe334f0/7bfe0e56?expires=1686246201', '286898202', null],
'vimeo without an id' => ['https://vimeo.com/statamic', null, null],
'vimeo with a malformed hash' => ['https://player.vimeo.com/video/735352648?h=x"><script>', '735352648', null],
'cloudflare' => ['https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/iframe', 'b209b1484821fff0ed2e5535fbb4ff7b', null],
'file' => ['https://example.com/clip.mp4', null, null],
'unsupported' => ['https://example.com/nope', null, null],
'null' => [null, null, null],
Expand All @@ -99,6 +117,7 @@ public static function trackableEmbedUrlsProvider()
'youtu.be' => ['https://youtu.be/FK3dav4bA4s', 'https://www.youtube.com/embed/FK3dav4bA4s'],
'youtu.be with a start time' => ['https://youtu.be/s72r_wu_NVY?t=559', 'https://www.youtube.com/embed/s72r_wu_NVY?start=559'],
'vimeo' => ['https://vimeo.com/22439234', 'https://player.vimeo.com/video/22439234'],
'cloudflare' => ['https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/watch', 'https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/iframe'],
'file' => ['https://example.com/clip.mp4', 'https://example.com/clip.mp4'],
'unsupported' => ['https://example.com/nope', 'https://example.com/nope'],
'empty' => ['', ''],
Expand Down
1 change: 1 addition & 0 deletions tests/Fieldtypes/VideoTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public static function augmentProvider()
{
return [
'url' => ['https://vimeo.com/22439234', 'vimeo', 'https://player.vimeo.com/video/22439234?dnt=1'],
'cloudflare' => ['https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/watch', 'cloudflare', 'https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/iframe'],
'file' => ['https://example.com/clip.mp4', 'file', 'https://example.com/clip.mp4'],
'unsupported' => ['https://example.com/nope', 'unsupported', null],
];
Expand Down
6 changes: 6 additions & 0 deletions tests/Modifiers/EmbedUrlTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ public function it_ensures_url_with_query_parameters_are_valid()
);
}

#[Test]
public function it_transforms_cloudflare_stream_urls()
{
$this->assertEquals('https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/iframe', $this->embed('https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/watch'));
}

#[Test]
public function it_gets_the_embed_url_from_an_augmented_video_value()
{
Expand Down
4 changes: 4 additions & 0 deletions tests/Modifiers/IsEmbeddableTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ public static function embeddablesProvider(): array
'youtube.com' => [true, 'https://www.youtube.com/watch?v=s9F5fhJQo34'],
'youtu.be' => [true, 'https://www.youtu.be/watch?v=s9F5fhJQo34'],
'vimeo' => [true, 'https://vimeo.com/22439234'],
'cloudflare' => [true, 'https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/iframe'],
'cloudflare manifest' => [false, 'https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/manifest/video.m3u8'],
'cloudflare without an id' => [false, 'https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/'],
'cloudflare download' => [false, 'https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/downloads/default.mp4'],
'other' => [false, 'http://video-home-system.com/video.mp4'],
];
}
Expand Down
6 changes: 6 additions & 0 deletions tests/Modifiers/TrackableEmbedUrlTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ public function it_transforms_youtube_urls()
);
}

#[Test]
public function it_transforms_cloudflare_stream_urls()
{
$this->assertEquals('https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/iframe', $this->embed('https://customer-wve7oze7jjxdb0j6.cloudflarestream.com/b209b1484821fff0ed2e5535fbb4ff7b/watch'));
}

public function it_ensures_url_with_query_parameters_are_valid()
{
$embedUrl = 'https://www.youtube-nocookie.com/embed/s72r_wu_NVY?pp=player_params';
Expand Down
Loading