Skip to content

[6.x] Support more video providers, including Cloudflare Stream - #11871

Draft
edalzell wants to merge 51 commits into
statamic:6.xfrom
edalzell:feature/add-cf-to-video-fieldtype
Draft

edalzell wants to merge 51 commits into
statamic:6.xfrom
edalzell:feature/add-cf-to-video-fieldtype

Conversation

@edalzell

Copy link
Copy Markdown
Contributor

@godismyjudge95

Copy link
Copy Markdown
Contributor

Might be worthwhile doing something similar to what I did with the embera tag - https://github.com/godismyjudge95/statamic-embera

Embera is a oembed client that takes in a video url and spits out an iframe. It has a decent number of video providers supported - https://github.com/mpratt/Embera/blob/master/doc/02-providers.md

@edalzell

Copy link
Copy Markdown
Contributor Author

Might be worthwhile doing something similar to what I did with the embera tag - godismyjudge95/statamic-embera

This is a really nice idea. I'll chat w/ the team on how they'd like to approach, as it's another dependency.

@duncanmcclean
duncanmcclean changed the base branch from ui to master June 30, 2025 18:42
@edalzell

edalzell commented Jul 1, 2025

Copy link
Copy Markdown
Contributor Author
  • 3 fields - url for pasting, dropdown for provider, video id
  • if you paste into the url, we'll extract the id and populate provider/id
  • save the provider/id

for augmenting maybe...

  • have a Video class with provider and id
  • the Video class would know how to get the regular and embed urls
  • __toString could output the non-embed url so {{ video_field }} on its own would continue to work
  • the embed_url modifier could handle a Video instance, so it could get the embed url from that

for backwards compat...

  • if your field has a url, it doesnt get augmented to a Video instance. it stays as a string.

@edalzell
edalzell marked this pull request as ready for review July 9, 2025 22:13
@edalzell
edalzell requested a review from jasonvarga July 9, 2025 22:13
@edalzell
edalzell requested a review from duncanmcclean March 19, 2026 22:44
@edalzell
edalzell requested a review from a team as a code owner June 9, 2026 20:51

@jasonvarga jasonvarga left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings from review (see inline comments). Cloudflare Stream preview is provably broken as written, plus a couple of warnings worth fixing before merge. Full writeup: the Cloudflare embed never renders because the Vue component sends a bare video ID while the backend expects a cloudflare:-prefixed string; the controller test only covers the prefixed form, so it doesn't catch this.

Comment thread resources/js/components/fieldtypes/VideoFieldtype.vue Outdated
Comment thread src/Http/Controllers/CP/Fieldtypes/VideoFieldtypeController.php Outdated
Comment thread resources/js/components/fieldtypes/VideoFieldtype.vue Outdated
Comment thread tests/Fieldtypes/VideoTest.php Outdated
Comment thread src/Fieldtypes/Video/Video.php Outdated
Comment thread resources/js/components/fieldtypes/VideoFieldtype.vue Outdated
@edalzell

edalzell commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all six. Cloudflare now sends the stored cloudflare: value as url, controller only reads url. Lazy-load gating restored, ID escaped with e(), .catch() toasts, Text tests removed. Also fixed a watcher bug where a lookup that changed the provider wiped the embed and URL input. Added Vitest coverage for the component, which is what the controller test couldn't catch.

@edalzell
edalzell requested a review from jasonvarga September 8, 2026 19:45
@duncanmcclean
duncanmcclean removed their request for review September 8, 2026 19:47

@jasonvarga jasonvarga left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the follow-ups — I've re-checked all six items from the last round and they're genuinely fixed: Cloudflare sends the prefixed value, lazy-load gating is back, the ID is escaped, failed lookups toast, the Text tests are gone, and the provider-watcher fix is a good catch. The new Vitest coverage is worth having.

Going through the whole thing again at this head, though, there's another set of issues I'd like sorted before this merges. Inline comments have the detail; the short version:

  1. Remote oEmbed HTML reaches v-html unsanitised, over an HTTP client with TLS verification disabled and no timeout.
  2. Lookups fire per keystroke with no debounce and no cancellation, so responses can land out of order and apply stale results.
  3. The component's local state is only seeded in mounted() and never re-syncs with value.
  4. Changing the provider clears the inputs but not the stored value.
  5. Direct video-file URLs (.mp4/.mov/.webm) lose the preview they had before.
  6. The provider dropdown is built from a different Embera collection than the lookup uses.
  7. preload() does an uncached, unguarded lookup per field render.
  8. augment() drops the provider, leaving no front-end path for Cloudflare.

A few smaller things that aren't blocking, so I haven't left inline comments for them: ~2.0 is the only tilde constraint in composer.json; Providers extends SlimProviderCollection just to reach its protected $providers; the Slim collection is mostly not video (Figma, Imgur, Reddit, Scribd, Twitter…) for a fieldtype called Video; the dropdown is inert on the URL path since detection overwrites your choice, and Not Supported is offered as a selectable option; there's a leftover @todo in preload(); savedValue doesn't need to be reactive; and the old inline URL validation message under the field is gone in favour of toast-only feedback.

One product question that I don't think was ever settled in the thread: this adds mpratt/embera as a hard dependency for every Statamic install. You mentioned you'd check with the team — did that land anywhere?

@blur="$emit('blur')"
/>

<div v-if="shouldShowPreview" v-html="embed"></div>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Provider-supplied HTML is rendered unsanitised in the CP.

Video::fromUrl() returns Embera's oEmbed html verbatim and this injects it with v-html. The old implementation assigned a derived URL to a locally-constructed <iframe>; this replaces that with arbitrary provider-controlled DOM inside an authenticated CP page. Vue doesn't sanitise v-html — an injected <script> won't execute, but active attributes like onerror will. The e($id) escaping added last round only protects the hand-built Cloudflare iframe, not any Embera response.

Scope is narrower than it first looks: YouTube and Vimeo define offline "fake responses" and Embera builds their markup from its own templates, so the common paths never render remote HTML. The exposure is the API-backed providers in the default collection — and see my comment on composer.json, which is what makes that markup substitutable in transit rather than merely third-party-trusted.

Either sanitise/allowlist the markup before it reaches v-html, or return validated provider + embed-URL data and build the iframe here in Vue. Worth a regression test with active HTML (e.g. an <img onerror>).

Comment thread composer.json Outdated
"league/glide": "^3.0 || ^4.0",
"maennchen/zipstream-php": "^3.1",
"michelf/php-smartypants": "^1.8.1",
"mpratt/embera": "~2.0",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth knowing what this dependency does on the wire.

Embera\Http\HttpClient::fetchWithCurl() sets CURLOPT_SSL_VERIFYPEER => 0, CURLOPT_SSL_VERIFYHOST => 0, CURLOPT_FOLLOWLOCATION => true, and no CURLOPT_TIMEOUT or CURLOPT_CONNECTTIMEOUT at all.

This PR makes that path reachable from the CP in two places: Video::preload() on every publish-form render of a populated field, and the new endpoint on every keystroke. A slow or unresponsive oEmbed endpoint pins a PHP-FPM worker indefinitely, and with verification off an on-path attacker can substitute the response — whose html goes straight into v-html.

Constraining to the Slim collection narrows this a lot; passing Embera your own HttpClientInterface closes it.

let htmlRegex = new RegExp(/<([A-Z][A-Z0-9]*)\b[^>]*>.*?<\/\1>|<([A-Z][A-Z0-9]*)\b[^\/]*\/>/i);
return htmlRegex.test(this.value || '');
},
getVideoData() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lookups fire per keystroke, uncancelled, and can land out of order.

detailsFromUrl is bound to ui-input's @update:model-value, and Input.vue emits that on every @input. Typing (rather than pasting) a 43-character YouTube URL fires 43 requests to this endpoint, each running a full Embera provider match server-side and, for API-backed providers, an outbound HTTP request.

The correctness half matters more than the volume: there's no debounce, no request identity check and no cancellation, so responses are applied in completion order. A lookup for an earlier partial URL that resolves late will overwrite embed and provider for the final value, leaving the preview and the provider disagreeing with what's actually stored. The same race lets a response land after a manual changeProvider and repopulate the previous provider's embed.

The mixin already ships the first half of the fix — Fieldtype.vue creates updateDebounced from UPDATE_DEBOUNCE_MS, and every other text-ish fieldtype uses it. For the second, tag each response with the value it was issued for and discard it unless that's still current.

const isVideo = url.includes('.mp4') || url.includes('.ogv') || url.includes('.mov') || url.includes('.webm');
return !this.isEmbeddable && isVideo;
},
setUrlOrId() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Local state never re-syncs with value.

url / videoId are populated exactly once, from setUrlOrId() in mounted(). embed / provider are read once from meta in data(). Nothing watches value.

So any external change to the field value — restoring a revision, "copy from origin" on a localisation, a field action, a programmatic set — leaves the input showing the old value while the model holds the new one. The pre-PR component was driven entirely off this.value and carried no local copy, so this is new behaviour.

Deriving url/videoId as computeds from this.value would be the cleanest fix; a watcher works too.

: embed_url.replace('watch?v=', 'embed/');
}
methods: {
changeProvider(provider) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing the provider leaves the stored value contradicting the UI.

This clears embed and url but never calls this.update(...), and never clears videoId.

Switch a populated YouTube field to Cloudflare and both inputs render empty while the model still holds the YouTube URL — save without typing anything and you've persisted a value the form wasn't showing. The reverse direction retains a stale cloudflare:<id> behind an empty URL input.

Clear both input states and emit a cleared value. The component test should assert the emitted update:value payload, not just the visible URL/embed state.

Comment thread src/Fieldtypes/Video/Providers.php Outdated
{
public static function get(): array
{
return collect((new self)->providers)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dropdown's provider list isn't the list the lookup uses.

This enumerates SlimProviderCollection — 25 providers. But Video::fromUrl() does new Embera(['responsive' => true]), which defaults to DefaultProviderCollection — several hundred.

So pasting a Wistia or Loom URL succeeds, sets this.provider to a name that isn't in providers, and the combobox renders blank against a field that's actually perfectly valid.

Pass the same collection to both sides. Using Slim for the lookup as well would also narrow the HTTP exposure I mentioned on composer.json, since Slim skews towards providers with offline responses.

Comment thread src/Fieldtypes/Video.php
return str($value)->afterLast(':')->value();
}

public function preload()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

preload() does an uncached, unguarded lookup per field render.

Every populated video field triggers Video::fromUrl() when the publish form renders. In a Replicator or Bard with a lot of video sets that's N lookups per page load with no caching.

There's also no try/catch — Embera's cURL path throws on a non-200, so a provider outage or a slow endpoint turns into a 500 on the publish form rather than just a missing preview.

Cache the result and degrade to notSupported() on failure.

Comment thread src/Fieldtypes/Video.php
{
protected $categories = ['media'];

public function augment($value)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

augment() drops the provider, and nothing downstream can recover it.

cloudflare:1234 augments to 1234. {{ video_field }} then emits a bare ID, and CoreModifiers::embedUrl() only understands YouTube and Vimeo so it hands the ID straight back. Meanwhile a YouTube value augments to the full URL.

Two consequences: the augmented type is provider-dependent with no discriminator, so template authors have to know which they're getting; and the headline feature of this PR has no first-class front-end rendering path — authors have to hardcode the Cloudflare iframe URL themselves.

Your own comment earlier in this thread proposed a Video value object carrying provider + id, with __toString() for BC and embed_url taught to accept it. That still seems like the right shape. This is new public augmentation behaviour that a major release locks in, so I'd rather settle it now than after.

Comment thread src/Fieldtypes/Video/Providers.php Outdated
->map(fn (string $class) => ['provider' => class_basename($class)])
->add(['provider' => 'Cloudflare'])
->sortBy('provider')
->add(['provider' => 'Not Supported'])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'Not Supported' is a user-facing string — it comes back in the endpoint's JSON and renders as a combobox option — but it isn't localised. Same for the one in Video::notSupported().

Worth doing now rather than later because the raw string is doubling as an identifier (the template branches on provider != 'Cloudflare'), so a later pass has to separate label from key rather than just wrapping it in __().


$this->assertSame('Cloudflare', $meta['providers'][0]['provider']);
$this->assertFalse(isset($meta['provider']));
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

augment() has no coverage here — it's new public behaviour with three branches (null, URL, prefixed ID) and nothing exercises it. Cheap to add, and it pins down the contract I raised on Video::augment().

While you're in here, nothing asserts the new video.details route requires authentication either.

@edalzell

Copy link
Copy Markdown
Contributor Author

Thanks — all eight are addressed. The dependency question drove most of the shape, so answering that first.

On mpratt/embera. It stays, but the CP no longer trusts it with either the network or the markup. Embera has a fake_responses mode where it resolves a URL to an embed URL from rules built into the library rather than calling the provider's oEmbed API. Lookups now try that first, so YouTube, Vimeo, DailyMotion, Loom, Streamable, Rumble, Coub and Ted resolve with zero HTTP requests — there's a test asserting that under Http::preventStrayRequests(). Only Wistia, TikTok, Vidyard and SproutVideo need a request, and those go through a HttpClientInterface implementation backed by Laravel's Http client (TLS verification on, 5s connect / 10s total timeout, redirects capped) and are cached for an hour. So CURLOPT_SSL_VERIFYPEER => 0 and the unbounded worker are both out of the picture.

Nothing provider-supplied reaches v-html anymorev-html is gone entirely. Video returns an embed_url extracted from the oEmbed html and validated (parses as a URL, https only, http upgraded, anything else rejected), and the component builds its own <iframe>. An <img onerror> in a provider response has nowhere to land; there's a regression test for exactly that payload. The Cloudflare ID is validated as alphanumeric rather than escaped, so the injection case returns "unsupported" instead of a mangled iframe.

The rest. The dropdown and the lookup now share one curated video-provider collection, so the combobox can't render blank against a valid value. Providers composes ProviderCollectionAdapter instead of extending Slim to reach a protected property, and options are {value, label} pairs so the label is translatable without doubling as the identifier — Not Supported is no longer a selectable option. The component derives url/videoId as computeds from value, so revisions and "copy from origin" re-sync; changeProvider now emits a cleared value; lookups use the mixin's updateDebounced plus an AbortController and a value check before applying a response. preload() is cached and guarded. The stray @todo, savedValue reactivity and the ~2.0 constraint are gone, and the inline "not supported" message is back alongside the toast.

augment() — one thing worth your call. It now returns the Video value object, following ArrayableString/ArrayableLink: __toString() gives the original URL so {{ video_field }} is unchanged, and embed_url/provider/id are available as properties. embedUrl, trackableEmbedUrl and isEmbeddable accept it, so Cloudflare finally has a front-end path.

The caveat: I return the object for all values, including plain URLs. Earlier in this thread I'd suggested URL-valued fields stay strings. Uniform is cleaner and means template authors don't have to know which they're getting, but it is a behaviour change — __toString() covers output and modifiers, though a strict is_string() check downstream would now fail. Happy to switch to returning strings for URLs if you'd rather keep that surface untouched for 6.x.

@edalzell

Copy link
Copy Markdown
Contributor Author

This had grown to ~1000 lines across 17 files covering four separable concerns, which is a lot to ask of a review. I've split it into a series so it can land incrementally, and so the dependency question doesn't block the rest.

The first two are independent; the last three stack in order. Everything you asked for is in there — the v-html removal, TLS/timeouts, debounce and cancellation, the value re-sync, changeProvider clearing the stored value, cached lookups, and the localised provider labels.

The useful part of the split: Cloudflare Stream needs no new dependency at all. Only #15460 adds mpratt/embera, so if the team would rather not take it, everything else still ships. I've answered the dependency question directly over there — short version, YouTube and Vimeo resolve entirely offline, so it only earns its keep for the long tail.

Leaving this open until the others land, then I'll close it in favour of #15460. Happy to reorder or drop any of them.

@edalzell
edalzell marked this pull request as draft September 14, 2026 23:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Cloudflare Stream to Video fieldtype

4 participants