Conversation
|
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 |
This is a really nice idea. I'll chat w/ the team on how they'd like to approach, as it's another dependency. |
for augmenting maybe...
for backwards compat...
|
jasonvarga
left a comment
There was a problem hiding this comment.
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.
…tcher wiping the embed
|
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. |
jasonvarga
left a comment
There was a problem hiding this comment.
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:
- Remote oEmbed HTML reaches
v-htmlunsanitised, over an HTTP client with TLS verification disabled and no timeout. - Lookups fire per keystroke with no debounce and no cancellation, so responses can land out of order and apply stale results.
- The component's local state is only seeded in
mounted()and never re-syncs withvalue. - Changing the provider clears the inputs but not the stored value.
- Direct video-file URLs (
.mp4/.mov/.webm) lose the preview they had before. - The provider dropdown is built from a different Embera collection than the lookup uses.
preload()does an uncached, unguarded lookup per field render.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> |
There was a problem hiding this comment.
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>).
| "league/glide": "^3.0 || ^4.0", | ||
| "maennchen/zipstream-php": "^3.1", | ||
| "michelf/php-smartypants": "^1.8.1", | ||
| "mpratt/embera": "~2.0", |
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
| { | ||
| public static function get(): array | ||
| { | ||
| return collect((new self)->providers) |
There was a problem hiding this comment.
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.
| return str($value)->afterLast(':')->value(); | ||
| } | ||
|
|
||
| public function preload() |
There was a problem hiding this comment.
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.
| { | ||
| protected $categories = ['media']; | ||
|
|
||
| public function augment($value) |
There was a problem hiding this comment.
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.
| ->map(fn (string $class) => ['provider' => class_basename($class)]) | ||
| ->add(['provider' => 'Cloudflare']) | ||
| ->sortBy('provider') | ||
| ->add(['provider' => 'Not Supported']) |
There was a problem hiding this comment.
'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'])); | ||
| } |
There was a problem hiding this comment.
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.
|
Thanks — all eight are addressed. The dependency question drove most of the shape, so answering that first. On Nothing provider-supplied reaches 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.
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 — |
|
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 The useful part of the split: Cloudflare Stream needs no new dependency at all. Only #15460 adds Leaving this open until the others land, then I'll close it in favour of #15460. Happy to reorder or drop any of them. |
Closes statamic/ideas#1336